diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b704c0f881..a8c20e1536 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,6 +53,9 @@ jobs: - name: Run Permission suite with the database cache store run: CACHE_STORE=database vendor/bin/paratest -c phpunit.xml.dist tests/Permission + - name: Run PHPStan analyzer suite + run: vendor/bin/paratest -c phpunit.phpstan.xml.dist + - name: Run Testbench package-mode suite run: php src/testbench/bin/testbench package:test --parallel tests/Testbench diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 8cf1b31caa..4b54b298ca 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -124,6 +124,8 @@ ->notPath('src/data/src/Support/Creation/NativeScalar.php') ->notPath('tests/Data/Fixtures/PhpDocTypeContext.php') ->notPath('tests/Foundation/Fixtures/fake-compiled-view.php') + // Analyzer data and the fixture application mirror upstream Larastan fixtures, whose syntax and line numbers are deliberate. + ->notPath('#^tests/PHPStan/((.+/)?data|application)/#') ->name('hypervel-test-profile') ->in(__DIR__) ) diff --git a/AGENTS.md b/AGENTS.md index 621b48f3f0..37f009d9d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ ParaTest defaults to the machine CPU count when no process count is specified. R To run a single test class: `./vendor/bin/phpunit --no-progress path/to/TestClass.php`. +The analyzer tests in `tests/PHPStan` run as a separate suite: use `composer test:phpstan`, or `./vendor/bin/phpunit -c phpunit.phpstan.xml.dist --no-progress path/to/TestClass.php` for a single class. + ## Change Workflow ### Classify the change @@ -117,7 +119,7 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi **Run verification commands sequentially** — Never overlap formatting, static analysis or tests. Wait for each command to finish before starting another. A tool’s built-in parallelism, such as ParaTest workers, is fine. -**During implementation**, run each new or changed test file immediately with `./vendor/bin/phpunit --no-progress path/to/TestClass.php`. +**During implementation**, run each new or changed test file immediately with `./vendor/bin/phpunit --no-progress path/to/TestClass.php` (add `-c phpunit.phpstan.xml.dist` for `tests/PHPStan`). Do not routinely run formatting or PHPStan during edits. Run targeted PHPStan earlier only when investigating a typing issue or when its feedback is needed to guide implementation. @@ -138,8 +140,7 @@ Apply approved adaptations when porting or modifying code. Do not expand a chang ### Working rules - **Never use subagents without explicit user consent** — Do not spawn or delegate work to subagents unless the user explicitly requests or approves their use. -- **Avoid bulk modification tools** — tools like `sed` and `replace_all` often have unwanted side effects. Never use bulk modification tools without explicit user approval; prefer manual edits. When approved, run them in multiple passes that each target long, exact, case-sensitive strings to avoid accidental changes. -- **One file at a time** — never work on multiple files simultaneously. This governs manual editing; package-manager and formatter runs may touch multiple files. +- **One change at a time** — Work on one coherent change at a time. Sequence dependent edits, and review the complete affected behavior across files before considering the change finished. - **Never use Write to overwrite files** — always use Edit for targeted updates. - **Always use `cp` to copy files and `mv` to move/rename** — never read → write new version → delete old version. - **Copy before splitting** — When a new file or class is primarily extracted from existing code, use `cp` to copy the primary source first and then update the copy rather than rebuilding it from individual pieces. Copy any additional blocks, including comments and docblocks, into the destination before removing them from their source. @@ -456,6 +457,9 @@ Package-specific tests that require one database driver go in `tests/Integration |-------|----------| | `Hypervel\Tests\TestCase` | Unit tests, mocks only, no container needed. Tests that only need an isolated scratch directory stay here — use `ParallelTesting::tempDir()` (see Temp directories below). | | `Hypervel\Testbench\TestCase` | Integration tests (needs container for facades, config, DB, etc.) **or any test that needs a full app skeleton / writes through `BASE_PATH`** — testbench clones a disposable runtime skeleton per run and exposes its path via `BASE_PATH` (and `TESTBENCH_BASE_PATH` for subprocesses), deleted on shutdown. Committed source is never mutated. | +| `PHPStan\Testing\RuleTestCase` | PHPStan rule tests in `tests/PHPStan`. | +| `PHPStan\Testing\TypeInferenceTestCase` | PHPStan type-inference tests in `tests/PHPStan`. | +| `PHPStan\Testing\PHPStanTestCase` | PHPStan tests in `tests/PHPStan` that need PHPStan's service container. | Always call `parent::setUp()` in your setUp method. @@ -509,7 +513,7 @@ The Testbench skeleton clone is shared for the whole worker, so tests that write ### Coroutine support -All tests run inside coroutines by default. The `RunTestsInCoroutine` trait is on both base test cases (`Hypervel\Tests\TestCase` and `Hypervel\Foundation\Testing\TestCase` / Testbench), so each test method automatically runs in a fresh coroutine. Context is destroyed when the coroutine ends — no manual cleanup needed. +Tests using Hypervel's base classes run inside coroutines by default. The `RunTestsInCoroutine` trait is on both base test cases (`Hypervel\Tests\TestCase` and `Hypervel\Foundation\Testing\TestCase` / Testbench), so each test method automatically runs in a fresh coroutine. Context is destroyed when the coroutine ends — no manual cleanup needed. **Never add `use RunTestsInCoroutine;` to individual test classes.** It's inherited from the base class. If you encounter a test extending raw `PHPUnit\Framework\TestCase`, change it to extend `Hypervel\Tests\TestCase` instead. @@ -762,6 +766,8 @@ See the existing entries for database, Redis, Meilisearch, and Typesense as exam ## Static Analysis +Components is analyzed with its own `hypervel/phpstan` extension. [Static Analysis](src/docs/static-analysis.md#fixing-and-ignoring-diagnostics) is the source of truth for each diagnostic's intended fix and legitimate exceptions; the rules below add Components-specific requirements. + The `tests/` directory is excluded from phpstan. Do not run phpstan on tests. Run full PHPStan checks with `composer analyse`. During implementation, use targeted PHPStan only when investigating or validating a specific type issue. @@ -771,15 +777,14 @@ Run full PHPStan checks with `composer analyse`. During implementation, use targ **When fixing phpstan errors:** 1. **Investigate before coding.** For each error: read the code, check the Laravel equivalent's types (native and docblock), trace through callers and dependents. Report findings with the single, most correct fix. -2. **Don't make the code worse or more convoluted just to satisfy PHPStan.** Fix real issues in the code, but don't add awkward wrappers, fake branches, casts, or wider types just to silence PHPStan. A phpstan fix is a typing change: it must not change runtime behavior, add overhead, or introduce new edge cases. If you cannot satisfy PHPStan within those constraints, reach consensus on a compliant solution through a second-opinion loop with your peer agent. If the code is correct and PHPStan cannot understand it, follow the narrowing / suppression order below. -3. **Native types vs docblocks determine what's dead code.** If a native return type makes a guard unreachable, the guard is dead code — remove it. If only a docblock suggests always-true, the guard is legitimate runtime defense — leave it. +2. **Don't make the code worse or more convoluted just to satisfy PHPStan.** Fix real issues in the code, but don't add awkward wrappers, fake branches, casts, or wider types just to silence PHPStan. A type-only fix must not change runtime behavior, add overhead, or introduce new edge cases. A genuine runtime bug that PHPStan exposes is fixed through the normal bug-fix workflow. If you cannot satisfy PHPStan within those constraints, reach consensus on a compliant solution through a second-opinion loop with your peer agent. If the code is correct and PHPStan cannot understand it, follow the source-first rule below. +3. **Check the supported runtime contract before removing guards.** Preserve necessary validation of caller input, consumer configuration and external data. Remove checks that only repeat guarantees the code already enforces; PHPDoc types and facts inferred from the booted application are not such guarantees. 4. **Don't change contract/concrete boundaries to fix phpstan.** Swapping a contract for a concrete (or vice versa) to satisfy a type check diverges from Laravel's API. Only do this when Laravel's typing is genuinely incorrect. 5. **Methods can be added to contracts only if they represent behavior any conforming implementation must provide.** Implementation-specific methods, internal helpers, or driver-specific features don't belong on contracts — find another fix even if adding them would satisfy phpstan. -6. **Wrong docblock types should be fixed**, not suppressed. Check the actual runtime behavior (extension docs, reflection, tests) to determine the correct type. -7. **Type decisions must be evidence-based.** See Development Conventions — check Laravel/Hyperf signatures and docblocks, then trace real control flow. Don't guess. -8. **Narrowing / suppression order.** When the code is correct but PHPStan can't follow it, in order: (1) fix the type signature or docblock; (2) `@var` to narrow to the correct runtime type; (3) a line- or identifier-scoped `@phpstan-ignore` (e.g. magic `__call`/`__get` forwarding). Never use `assert()` to narrow types, and never add a neon-wide rule on your own (see #9). - When a container string key is also a PHP class name, keep the canonical service key and use `@var` for its actual runtime type; do not change service resolution solely for PHPStan. -9. **Don't add patterns to `phpstan.neon.dist` on your own.** The neon file's global ignores cover fundamental framework patterns (Eloquent magic, generics, `new static`). Fix new phpstan errors at the source, not by masking them with new neon rules. Global suppression is a rare exception. First, reach consensus through a second-opinion loop with your peer agent that the error cannot be fixed at the source or narrowed locally, and that the pattern is frequent enough to justify global suppression. Then STOP, explain the reason and recommended suppression to the owner, and wait for approval before adding it. +6. **Type decisions must be evidence-based.** See Development Conventions — check Laravel/Hyperf signatures and docblocks, then trace real control flow. Don't guess. +7. **Fix types at their source.** Correct signatures, PHPDoc, generics, stubs or extensions. Use `assert()` only for genuine invariants; assertions may be disabled and cannot replace required validation. A truthful inline `@var` is a last resort. For verified analysis limitations, use `@phpstan-ignore identifier (reason)`, always including a meaningful reason; never use identifier-less ignores. For a confirmed PHPStan bug, start the reason with `@TODO: Remove once PHPStan` and describe the needed fix; do not add a `docs/todo.md` entry. NEON ignores require approval under the global-ignore rule. +8. **Don't add patterns to `phpstan.neon.dist` on your own.** The neon file's global ignores cover fundamental framework patterns (Eloquent magic, generics, `new static`). Fix new phpstan errors at the source, not by masking them with new neon rules. Global suppression is a rare exception. First, reach consensus through a second-opinion loop with your peer agent that the error cannot be fixed at the source or narrowed locally, and that the pattern is frequent enough to justify global suppression. Then STOP, explain the reason and recommended suppression to the owner, and wait for approval before adding it. +9. **Environment reads outside config files.** Framework source reads the environment outside config files only for a deliberate infrastructure need, such as reads before configuration loads, process transport like `__HYPERVEL_CONTEXT`, or test infrastructure like worker tokens. Use `Env::get()` with an inline `@phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (reason)` stating that need. `Env::get()` is the convention for these intentional reads; switching from `env()` does not fix a read in the wrong place. Do not add directory, path or class-wide exemptions. ## Porting Packages @@ -959,7 +964,7 @@ Tests for these features should be **removed** (not commented out) without askin 1. Update namespace to `Hypervel\Tests\{Package}` 2. Add `declare(strict_types=1);` 3. Change `Illuminate\` imports to `Hypervel\` -4. Extend correct base TestCase (`Hypervel\Tests\TestCase` or `Hypervel\Testbench\TestCase`) +4. Extend the appropriate test base listed under [Base classes](#base-classes) 5. Ensure `parent::setUp()` is called 6. Add type declarations to model properties 7. Fix mock types (PDO, QueryBuilder, Grammar, etc.) diff --git a/bin/sync-skeleton-config/README.md b/bin/sync-skeleton-config/README.md index bbea93f4ed..f8328e0db6 100644 --- a/bin/sync-skeleton-config/README.md +++ b/bin/sync-skeleton-config/README.md @@ -57,7 +57,7 @@ The replacement above illustrates the syntax; it is not a required React setting `omit` lists existing keys to remove. `replace` maps existing keys to PHP source expressions, represented as strings. Dotted keys address nested literal arrays. Rules cannot traverse a function call or other computed value. Missing keys, missing source files, unknown operations, and unregistered directory names in rules fail rather than silently doing nothing. Register a new skeleton by creating its directory and adding its name under `skeletons`; its `config/` directory is created by sync. -The tool parses PHP without executing it, preserving environment expressions, comments, and formatting. Omitting an entry removes its attached comments and any imports no longer used by the remaining config. The exact framework-only `// @phpstan-ignore class.notFound` comment is stripped from generated files; other comments and string contents are retained. +The tool parses PHP without executing it, preserving environment expressions, comments, and formatting. Omitting an entry removes its attached comments and any imports no longer used by the remaining config. The framework-only `// @phpstan-ignore class.notFound (...)` comment is stripped from generated files; other comments and string contents are retained. Both sync and check reject unexpected skeleton config files before writing anything. When removing or renaming a framework config, remove its old skeleton copies too; the error lists any that remain. diff --git a/bin/sync-skeleton-config/sync.php b/bin/sync-skeleton-config/sync.php index b6535341fb..8e298870db 100644 --- a/bin/sync-skeleton-config/sync.php +++ b/bin/sync-skeleton-config/sync.php @@ -204,7 +204,7 @@ protected function transform(string $source, array $operations, string $context) // App classes do exist in skeletons; this suppression is needed only when // PHPStan analyzes the framework's defaults outside an application. foreach (array_reverse(PhpToken::tokenize($source)) as $token) { - if ($token->id === T_COMMENT && trim($token->text) === '// @phpstan-ignore class.notFound') { + if ($token->id === T_COMMENT && str_starts_with($token->text, '// @phpstan-ignore class.notFound (')) { $start = $token->pos; while ($start > 0 && $source[$start - 1] === ' ') { diff --git a/composer.json b/composer.json index 2075074e3b..741144d71c 100644 --- a/composer.json +++ b/composer.json @@ -98,8 +98,8 @@ "symfony/error-handler": "^8.1", "symfony/event-dispatcher": "^8.1", "symfony/finder": "^8.1", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/mailer": "^8.1", "symfony/mime": "^8.1", "symfony/polyfill-php85": "^1.36", @@ -107,8 +107,8 @@ "symfony/process": "^8.1", "symfony/property-access": "^8.1", "symfony/psr-http-message-bridge": "^8.1", - "symfony/routing": "^8.1", - "symfony/serializer": "^8.1", + "symfony/routing": "^8.1.11", + "symfony/serializer": "^8.1.11", "symfony/uid": "^8.1", "symfony/var-dumper": "^8.1", "symfony/yaml": "^8.1.11", @@ -133,14 +133,15 @@ "mockery/mockery": "^1.6.15", "opis/json-schema": "^2.6.0", "pda/pheanstalk": "^8.0.2", - "phpstan/phpstan": "2.3.1", + "phpstan/phpstan": "^2.3.5", + "phpstan/phpstan-deprecation-rules": "^2.0.5", "phpunit/phpunit": "~13.4.3", "pusher/pusher-php-server": "^7.2", "resend/resend-php": "^1.0", "spomky-labs/cbor-php": "^3.4.1", "swoole/ide-helper": "^6.0.2", - "symfony/filesystem": "^8.1", - "symfony/http-client": "^8.1", + "symfony/filesystem": "^8.1.11", + "symfony/http-client": "^8.1.11", "symfony/mailgun-mailer": "^8.1", "symfony/mercure": "^0.8", "symfony/postmark-mailer": "^8.1", @@ -205,6 +206,7 @@ "hypervel/passport": "self.version", "hypervel/pennant": "self.version", "hypervel/permission": "self.version", + "hypervel/phpstan": "self.version", "hypervel/pipeline": "self.version", "hypervel/process": "self.version", "hypervel/prompts": "self.version", @@ -302,6 +304,7 @@ "Hypervel\\Passport\\Database\\Factories\\": "src/passport/database/factories/", "Hypervel\\Pennant\\": "src/pennant/src/", "Hypervel\\Permission\\": "src/permission/src/", + "Hypervel\\PHPStan\\": "src/phpstan/src/", "Hypervel\\Pipeline\\": "src/pipeline/src/", "Hypervel\\Process\\": "src/process/src/", "Hypervel\\Prompts\\": "src/prompts/src/", @@ -370,6 +373,18 @@ "Workbench\\Database\\Factories\\": "src/testbench/workbench/database/factories/", "Workbench\\Database\\Seeders\\": "src/testbench/workbench/database/seeders/" }, + "classmap": [ + "tests/PHPStan/Integration/data", + "tests/PHPStan/Rules/Coroutine/data", + "tests/PHPStan/Rules/data", + "tests/PHPStan/Rules/Queue/data", + "tests/PHPStan/Rules/Validation/data", + "tests/PHPStan/Type/data", + "tests/PHPStan/Unit/data" + ], + "exclude-from-classmap": [ + "tests/PHPStan/**/*-fixed.php" + ], "files": [ "tests/Database/Fixtures/MigrationCreatorFakeMigration.php" ] @@ -478,6 +493,7 @@ "check": [ "@check:skeleton-config", "@test", + "@test:phpstan", "@test:testbench", "@test:dogfood", "@analyse", @@ -490,6 +506,7 @@ "@sync:skeleton-config", "@analyse", "@test:parallel", + "@test:phpstan", "@test:testbench", "@test:dogfood" ], @@ -521,6 +538,10 @@ "Composer\\Config::disableProcessTimeout", "paratest" ], + "test:phpstan": [ + "Composer\\Config::disableProcessTimeout", + "paratest -c phpunit.phpstan.xml.dist" + ], "test:profile": [ "Composer\\Config::disableProcessTimeout", "@php src/testing/bin/hypervel-test-profile" diff --git a/docs/plans/2026-11-04-hypervel-phpstan-checklist.md b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md new file mode 100644 index 0000000000..3d1c36c774 --- /dev/null +++ b/docs/plans/2026-11-04-hypervel-phpstan-checklist.md @@ -0,0 +1,36 @@ +# Hypervel PHPStan remaining-file checklist + +Working checklist for [2026-11-04-hypervel-phpstan.md](2026-11-04-hypervel-phpstan.md). Remove entries once handled. Paths are relative to `/home/binaryfire/workspace/references/larastan` unless they start with `src/` or `tests/` in this repository. + +Actions: **copy** (cp, read, adapt), **move** (mv existing Hypervel file), **merge** (combine into the named file), **add** (new Hypervel file), **omit** (record per the porting policy). + +Destinations: package root `src/phpstan/`; upstream `src/` -> `src/phpstan/src/`; `stubs/` -> `src/phpstan/stubs/`; `tests/` -> `tests/PHPStan/`. + +## Package root + +- omit: `phpcs.xml.dist`, `phpstan.neon.dist`, `phpstan-baseline.neon`, `phpunit.xml.dist`, `phpunit-bootstrap.php`, `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `RELEASE.md`, `UPGRADE.md`, `assets/` (repository tooling; the components repo owns its own) +- omit: `docs/errors-to-ignore.md` (higher-order messages on support collections are typed, and NEON pattern ignores are not recommended) +- omit: `e2e/` Laravel project baselines (replaced by the plan's real-project checks) + +## src + +- omit: `Internal/ComposerHelper.php` (`Support\Composer::vendorPath()` resolves the actual install) +- omit: `Properties/ReflectionTypeContainer.php` (unused since upstream removed container ArrayAccess support) +- omit: `Rules/DeferrableServiceProviderMissingProvidesRule.php`, `Rules/OctaneCompatibilityRule.php` +- omit: `SQL/*` (`hypervel/schema-parser` replaces the parser boundary and its iamcal/phpMyAdmin adapters) + +## stubs + +- omit, because the source declares their types: `common/{Attribute,Console/{Command,HasParameters},Contracts/{Events/Dispatcher,Kernel},Database/Eloquent/Concerns/{HasAttributes,HasEvents,HasRelationships},Database/{ManagesTransactions,Migrations/Migrator},Events/Dispatcher,Macroable,Notifications/Notification,Routing/UrlGenerator,Validation/Validator}.stub`, the `cookie()`, `logger()`, `logs()`, `redirect()`, `request()`, `session()` and `url()` entries of `common/Helpers.stub`, and the `common/{MailMessage,Route}.stub` properties and methods (now PHPDoc in `src/notifications` and `src/routing`); `EloquentCollection`, `MorphTo`, `MorphToMany`, `Pagination` and `Contracts/Pagination` likewise, with `toQuery()`'s return-type extension and `MorphTo`'s map refinements in the combined `Database/Eloquent/Relations.stub` +- omit: `common/{Contracts/{Cache,Container,Mail,Queue,UrlGenerator},Cookie/SymfonyCookie,Http/RedirectResponse,Routing/Redirector,Session/SessionManager,Support/{HtmlString,Manager,Optional},ThirdParty}.stub` (declarations for upstream's other stubs; no Hypervel stub references them) +- omit: `common/{Http/InteractsWithInput,Foundation/Http/FormRequest,Support/ValidatedInput}.stub`; `common/Http/Request.stub` stays an empty declaration (the source declares the request getter, `safe()` and `validated()` types) +- omit: `common/Redis/*.stub` and `common/Contracts/Redis.stub` (the source tags, `MultiExec` and mixins type the pooled surface; `@mixin \Redis` would drop the cluster unions and expose connection-bound methods), `common/Gate.stub` (Hypervel's `Gate::forUser()` and its contract already return `static`) +- omit: `common/Contracts/Translation.stub` (only upstream's omitted `trans()` helper stub references it) + +## tests + +- omit: `laravel-test.sh` +- omit: `application/app/{ModelWithConflictingInitializer.php,UserResource.php,Providers/}` (no upstream test or source references them) +- omit: `Rules/{DeferrableServiceProviderMissingProvidesRuleTest,OctaneCompatibilityRuleTest}.php`, `Rules/data/{CorrectDeferrableProvider,CorrectDeferrableProviderIndirect,IncorrectDeferrableProvider}.php`, `Rules/data/octane.neon`, `Rules/data/ContainerInjection.php` +- omit: `Type/RedisDriver{Null,Predis}TypeTest.php` with their data (the `redisDriver` setting is omitted), `Type/data/logger.php` (never registered upstream) and `Type/data/config-with-migrations*.neon` (`phpstan-tests.neon` matches them) +- omit: `Unit/SQL/*` (parser factory and phpMyAdmin adapter tests; `hypervel/schema-parser` has its own) diff --git a/docs/plans/2026-11-04-hypervel-phpstan.md b/docs/plans/2026-11-04-hypervel-phpstan.md new file mode 100644 index 0000000000..811485ac78 --- /dev/null +++ b/docs/plans/2026-11-04-hypervel-phpstan.md @@ -0,0 +1,791 @@ +# Hypervel PHPStan package + +## Outcome and working state + +Build `hypervel/phpstan` as a tracked Larastan port in `src/phpstan`, published through the normal component split. It supplies framework-aware type inference and diagnostics for the components repo, applications, third-party packages, and the private `packages/hypervel` and `packages/hypervel-dev` repositories. All reusable PHPStan extensions currently owned by Database, Foundation, Head and Scout move into this package. Ordinary source types remain in their owning components. + +Worktree: `/home/binaryfire/workspace/contrib/hypervel/components-phpstan`; branch: `feature/phpstan-package`, based on `0.4`. This worktree has its own Composer installation and a copy of the components repo's `.env`. Do not share vendor directories between worktrees. Consumer migration changes are made directly in `/home/binaryfire/workspace/monorepo` on `main`, in the existing consumer locations, under its own instructions: no consumer worktrees, unrelated uncommitted changes preserved, no stashing. + +This document specifies the final design and implementation work. Read it, the root monorepo `CLAUDE.md`, the components repo's `AGENTS.md`, and relevant package READMEs after compaction. Follow their approval, porting and testing rules and the owner's authorized implementation workflow. Instruction-file edits and publication require explicit owner approval. + +Remaining work: + +- Completed: PHPStan `^2.3.5` minimum (root, base skeleton, analyzer manifest; Database and Foundation no longer declare PHPStan), regression ignores, corrected split types, the DataTypeFactory parent-resolution fix, package metadata and analyzer test harness, moved extensions, and bootstrap/error reporting with `tests/PHPStan/BootstrapTest.php`. +- Also completed: the package-mode boot and Composer-install fixes, container typing (Bootstrap and runtime discovery) with the source typing fixes it exposed, and the initial `GeneralTypeTest`. +- Also completed: view and translation analysis (collectors, the missing-translation and unused-view rules, `view-string` types and stubs, with-method reflection, translation return types) with their tests. +- Also completed: the authentication flow (guard and user return types, the request-scope rules, the fixture app's auth models and config) with `AuthGuardTypeTest` for configuration-dependent guard typing. +- Also completed: console command analysis (argument/option return types, the undefined-input rule, the fixture commands and the `Artisan` facade stub). +- Also completed: configuration inference and diagnostics, environment-reader policy and source types, consumer config contracts, and the Foundation/Testbench nested-config loader fix, with their regression coverage. +- Also completed: the independent audit. The owner approved its changes, which this plan now specifies: config-file `@return` types regardless of `treatPhpDocTypesAsCertain`, actionable config diagnostics, the canonical Static Analysis page (see Documentation), the targeted AGENTS.md edits (see Completion criteria) and the warm-run cache reuse check. +- The approved audit source, documentation and AGENTS.md changes are applied. +- Also completed: the helpers and collections flow (higher-order proxies, `Arr` types and `ArrKeyExistenceRule`, collection narrowing, helper extensions, the enumerable `toArray()` and useless-construct rules, collection stub types moved to source), with its bleeding-edge type and integration tests except the model-dependent fixtures listed in the checklist. +- Also completed: the components repo's stale-ignore audit, with its `reportUnmatchedIgnoredErrors: false` override removed. The two private packages' overrides go with their analyzer adoption (see Consumer migration), because their ignores must be audited against this analyzer. +- Also completed: the macro and forwarding flow (Macros; facade, storage and manager forwarding; factory-helper bindings), with the `Support\Manager::getCreatorMethod()` runtime fix and its regression tests. +- Also completed: the request, environment and storage flow, Redis command casing and the connection-bound Redis rule, with their source fixes and tests (see Requests, environment and storage). +- Also completed and reviewed: the model-properties/schema flow, including schema-parser impact, casts/accessors, fetched types, schema and forwarding dependencies, the schema-dump diagnostic, the routing and SQLite JSON fixes, Attribute variance, inherited method-template declarations, tests and documentation. +- Also completed: the approved `final` cleanup in the PHPStan and schema-parser packages and branch-added fixtures. `readonly` is preserved; the `Comment`/`Comments` type fixtures are unchanged. +- Also completed: the Eloquent builder and relation forwarding flow (`BuilderHelper`, the `builder-of`, `collection-of` and `relation-of` types, `ClosureTypes/`, the forwarding return types, the 13.x stub merges, dynamic wheres and stub-validation compliance), with its type and integration fixtures. +- Also completed: the upstream Integration merge, with its source corrections and `RelationExistenceRule`. +- Also completed: Laravel's `@return $this` tags restored across the components repo (see Type inference and stubs). +- Also completed: the Eloquent collection flow (`EloquentCollectionMapDynamicReturnTypeExtension`, `toQuery()` typing, the eager-load map types, the strict `relation-of` union and the model-dependent collection fixtures). +- Also completed: the factory flow, with its runtime fixes and tests (see Factories under Type inference and stubs). +- Also completed: the queue and dispatch flow (dispatch arguments, model serialization, unique, batch and transaction dispatch rules with `InspectsQueuedJobs`), with the `PendingBatch` runtime fix and warm-edit regressions. Parents' private properties are now serialized and read through the cached `ClassMetadataCache::allProperties()`: `SerializesModels` (keyed by declaring class, with raw reads and writes so property hooks don't run), Horizon `Tags` and Telescope `ExtractTags`/`ExtractProperties` (a name an earlier property uses is keyed `Class::$name`). `Model::__sleep()` names stored properties with `(array) $this`, keeping subclass private and backed hooked state without running hooks; protected names stay plain so overrides filtering `parent::__sleep()` keep working. +- Also completed: the chain and fake dispatch precedence fixes required before the queue-property rule (see Legacy authoring policy). +- Also completed: the cancellation, raw-sleep and transaction-spawn rules with their components repo conversions and runtime tests (see Cancellation and Worker lifetimes and coroutine context). +- Also completed: the owner-approved PHPStan workarounds (the attribute-service result-cache metadata extension under Result-cache correctness, the unpacked-argument correction under Authentication) and the deprecation-rules includes in both components repo configs, with their source fixes and the validation-contract ignores (see Deprecated validation contracts). +- Also completed: telemetry cancellation completion and the worker-state rules, with the controller lifetime correction (see Telemetry cancellation completion and Worker lifetimes and coroutine context). +- Also completed: the pipe-separated validation rules, with the Fortify and documentation conversions (see Legacy authoring policy). +- Also completed: the remaining stubs and type tests, with the forwarded `when()`/`unless()` typing for models and relations and the source PHPDoc that replaces upstream stubs (the checklist's `src/`, stubs and tests sections hold only recorded omissions). +- Also completed: `reportIgnoresWithoutComments` in `extension.neon`, with the components repo's reason-less and blanket ignores (source and `types/`) given reasons, fixed at the source or removed. `InteractsWithTestCase` is imported through several Testbench traits, so its `WithFixtures` call is reported once per import and has an owner-approved, path-scoped entry in `phpstan.neon.dist`. `bin/sync-skeleton-config` strips the framework-only `class.notFound` ignore by prefix, whatever its reason. +- Also completed: the upstream docs merge into the Static Analysis page (Configuration, the custom PHPDoc types, model serialization, relationships, custom builders and collections, and model columns). +- Also completed: the shared runtime-input tracking and its warm-versus-cold and unchanged warm-run reuse checks for an application and a Testbench package (see Result-cache correctness and analysis cost). +- Also completed: the final efficiency pass for the components repo (see Result-cache correctness and analysis cost); application timings remain with consumer migration. +- Also completed: package-mode config composition (see Static configuration), with the package-default workarounds removed and the skeleton's `phpstan.neon` reduced to PHPStan's defaults. +- Also completed: the final review and cleanup of the components repo work under Consumer migration and final cleanup. +- Also completed: the owner-approved `Verified` fix exposed by the consumer audit. `Verified::$user` is natively `Authenticatable|MustVerifyEmail`, because explicit verification (`EmailVerificationRequest::fulfill()`, Fortify and Workbench) dispatches it for authenticated users that only use the `MustVerifyEmail` trait, as Laravel's untyped event allows, while objects implementing only the contract stay accepted. +- Also completed: the framework/package integration review across all checkpoints. +- Also completed: the independent full audits of the PHPStan package by both implementers, with their agreed corrections, which this plan now specifies. +- Next: code review of the current PR review corrections, then the push and the owner's decision on merging this branch. After the approved merge, adoption in the private packages and apps happens directly on monorepo `main`; shared concerns and helpers move with their first consumers, because PHPStan reports unused traits. Then review that consumer integration and complete the reporting (see Completion criteria). +- Complete the implementation and verification sections below, keeping the single remaining-file checklist in [2026-11-04-hypervel-phpstan-checklist.md](2026-11-04-hypervel-phpstan-checklist.md) during porting. + +## Design decisions + +- Port Larastan's architecture, file organization, naming, algorithms and applicable tests; track its upstream. Use `cp`, read the complete copy, then edit, one file at a time in alphabetical order. Use `mv` for existing Hypervel extensions. Preserve upstream member order and comments (unless incorrect). Fix inherited defects at their owning boundary. +- Boot the actual application; use Hypervel Testbench for packages. Retain runtime discovery of container bindings, macros, facade roots, authentication configuration and console definitions. Do not build a second static service-container simulator or require an alternative no-boot mode. +- Retain the upstream `extension.neon`, parameter schemas, PHPStan DI attributes and applicable rule switches. Select stricter Hypervel defaults. Keep settings for retained features, including paths and scanning/cache options; omit settings only when their feature is omitted. Do not implement anti-override mechanisms, non-ignorable policy errors, multiple strictness profiles, or a custom configuration language. +- Register `extension.neon` in `extra.phpstan.includes`. Document explicit inclusion and optional discovery through `phpstan/extension-installer`; installing the analyzer alone does not install or authorize that Composer plugin. +- Include maintained deprecation reporting through a required `phpstan/phpstan-deprecation-rules` dependency. Use normal `@deprecated Use X instead.` metadata for future Hypervel deprecations; do not build duplicate deprecation rules. Reporting covers known deprecated APIs from dependencies and PHP as well as Hypervel, subject to upstream metadata and deprecated-scope behavior. PHPStan's restricted-usage rules look members up on the plain class, so deprecated members reached through generic proxies (higher-order collection, `when()`, `Optional`, tap) are not reported; proxy reflections still carry the target's deprecation metadata, the docs state the limit, and no duplicate rule or dependency patch is added. +- The public configuration catalog below is the source of truth for defaults. Preserve PHPStan's own level, paths, parallelism, baseline and suppression settings. PHPStan already defaults unmatched-ignore reporting to true; remove the three consumer false overrides after auditing their ignores rather than redundantly setting that default in the extension. Stronger defaults do not change the meaning of retained options. +- Package-owned diagnostics use stable `hypervel.*` identifiers, state the concrete problem and give the replacement. Rename Larastan-owned identifiers (`larastan.*` and `rules.modelAppends`) only; preserve PHPStan-owned identifiers emitted by reused offset/argument checks, such as `offsetAccess.notFound`. Keep ordinary suppressions for legitimate exceptions and analyzer limitations. New rule switches follow Larastan's boolean parameter/conditional-registration convention; do not add detailed tuning without a real requirement. +- Use PHPStan rules, type/reflection extensions, restricted-usage extensions and collectors for their intended jobs. Collectors can supply cross-file diagnostic facts after analysis; they cannot feed same-run type inference. Share an existing helper where needed, not a parallel inference engine. +- Runtime work is confined to genuine source typing/correctness fixes exposed by this port and changes needed to support the analyzer correctly. Configurable authoring-policy diagnostics can recommend modern APIs while legacy APIs remain supported. The separate legacy-removal todo is not permission to remove runtime APIs in this port; see the legacy-policy section. +- Shared component release versions apply. New mandatory defaults belong in a `0.x` minor release; false-positive corrections can ship in patches. Do not introduce an independent analyzer release train. + +## Public configuration and identifiers + +Use this catalog for `extension.neon`, parameter schemas, documentation and assertions; do not maintain a second defaults list in the plan. All boolean defaults apply to the normal extension include, and consumers may override them. Retained settings keep upstream meanings. + +| Retained upstream parameter | Default | +|---|---| +| `noEnvCallsOutsideOfConfig` | `true` | +| `noModelMake` | `true` | +| `noImplicitQueryBuilderCall` | `false` | +| `noUnnecessaryCollectionCall` | `true` | +| `noUnnecessaryCollectionCallOnly` | `[]` | +| `noUnnecessaryCollectionCallExcept` | `[]` | +| `noUnnecessaryEnumerableToArrayCalls` | `true` | +| `squashedMigrationsPath` | `[]` | +| `databaseMigrationsPath` | `[]` | +| `disableMigrationScan` | `false` | +| `disableSchemaScan` | `false` | +| `configDirectories` | `[]` | +| `viewDirectories` | `[]` | +| `translationDirectories` | `[]` | +| `checkModelProperties` | `true` | +| `checkUnusedViews` | `false` | +| `checkMissingTranslations` | `true` | +| `checkModelAppends` | `true` | +| `checkModelMethodVisibility` | `true` | +| `checkConfigTypes` | `true` | +| `checkAuthCallsWhenInRequestScope` | `false` | +| `parseModelCastsMethod` | `true` | +| `enableMigrationCache` | `true` | +| `checkUniqueJobUniqueFor` | `false` | +| `checkUniqueJobUniqueId` | `true` | +| `noBatchedUniqueJob` | `true` | +| `checkJobSerializesModels` | `true` | +| `checkBatchedJobIsBatchable` | `true` | +| `checkBatchableJobChecksCancellation` | `false` | +| `checkDispatchInTransactionAfterCommit` | `true` | + +Omit `checkOctaneCompatibility`, `generalizeEnvReturnType` and `redisDriver` with their unsupported/incorrect mechanisms. Keep the existing Hypervel `eloquentBuilderMacros` map, with its SoftDeletes and optional Scout entries pointing to the moved signature interfaces; it is extension metadata, not a rule toggle. Preserve PHPStan's normal built-in settings and adapt framework-specific bootstrap/early-termination entries to the real Hypervel symbols. Keep upstream's `universalObjectCratesClasses` entries as `Hypervel\Http\Request` and `Hypervel\Support\Optional`, whose contracts permit arbitrary property names. Set the built-in `reportIgnoresWithoutComments` to `true`; leave `reportUnmatchedIgnoredErrors` at PHPStan's default `true`. + +| New Hypervel parameter | Default | Package-owned diagnostic identifier | +|---|---|---| +| `checkCancellationHandling` | `true` | `hypervel.cancellation.exactType` | +| `checkCancellationPrecedence` | `true` | `hypervel.cancellation.precedence` | +| `checkWorkerSharedState` | `true` | `hypervel.workerSharedState` | +| `checkStaticStateMutation` | `true` | `hypervel.staticStateMutation` | +| `checkUnboundedWorkerCaches` | `true` | `hypervel.unboundedWorkerCache` | +| `noRuntimeConfigMutation` | `true` | `hypervel.runtimeConfigMutation` | +| `noCoroutinesInTransactions` | `true` | `hypervel.coroutineInTransaction` | +| `noRawSleepCalls` | `true` | `hypervel.rawSleep` | +| `noLegacyModelScopes` | `true` | `hypervel.legacyModelScope` | +| `noLegacyModelProperties` | `true` | `hypervel.legacyModelProperty` | +| `noLegacyQueueProperties` | `true` | `hypervel.legacyQueueProperty` | +| `noPipeSeparatedValidationRules` | `true` | `hypervel.pipeSeparatedValidationRules` | +| `checkRedisConnectionBoundMethods` | `true` | `hypervel.redisConnectionBoundMethod` | + +`ConfigTypeRule` adds `hypervel.configType` under the retained `checkConfigTypes` switch (see Upstream rule disposition). `NoMissingTranslationsRule` adds `hypervel.invalidTranslationLocale` under the retained `checkMissingTranslations` switch (see Conservative construction and translation predicates). `UnusableSchemaDumpRule` adds `hypervel.schemaDump` with no switch (see Model schemas). + +The behavior and soundness limits of each new check are specified under Hypervel rules; a public name does not authorize speculative detection. Report one actionable diagnostic for the same underlying write rather than duplicating worker/static/config errors. + +Map upstream-owned identifiers exactly as follows. Core PHPStan offset/argument/type identifiers are unchanged, including when emitted by a ported rule. The two omitted diagnostics have no new identifier. + +| Upstream identifier | Hypervel identifier | +|---|---| +| `larastan.batchableJobChecksCancellation` | `hypervel.batchableJobChecksCancellation` | +| `larastan.batchedJobIsBatchable` | `hypervel.batchedJobIsBatchable` | +| `larastan.configCollection` | `hypervel.configCollection` | +| `larastan.console.undefinedArgument` | `hypervel.console.undefinedArgument` | +| `larastan.console.undefinedOption` | `hypervel.console.undefinedOption` | +| `larastan.deferrableServiceProvider.missingProvides` | Omitted with deferred-provider support | +| `larastan.dispatchInTransactionAfterCommit` | `hypervel.dispatchInTransactionAfterCommit` | +| `larastan.events.noConstructor` | `hypervel.events.noConstructor` | +| `larastan.jobSerializesModels` | `hypervel.jobSerializesModels` | +| `larastan.jobs.noConstructor` | `hypervel.jobs.noConstructor` | +| `larastan.missingTranslations` | `hypervel.missingTranslations` | +| `larastan.noAuthFacadeInRequestScope` | `hypervel.noAuthFacadeInRequestScope` | +| `larastan.noAuthHelperInRequestScope` | `hypervel.noAuthHelperInRequestScope` | +| `larastan.noBatchedUniqueJob` | `hypervel.noBatchedUniqueJob` | +| `larastan.noEnvCallsOutsideOfConfig` | `hypervel.noEnvCallsOutsideOfConfig` | +| `larastan.noImplicitQueryBuilderCall` | `hypervel.noImplicitQueryBuilderCall` | +| `larastan.noModelMake` | `hypervel.noModelMake` | +| `larastan.noPublicModelAccessorMethod` | `hypervel.noPublicModelAccessorMethod` | +| `larastan.noPublicModelScopeMethod` | `hypervel.noPublicModelScopeMethod` | +| `larastan.noUnnecessaryCollectionCall` | `hypervel.noUnnecessaryCollectionCall` | +| `larastan.octaneCompatibility` | Omitted with Octane support | +| `larastan.relationExistence` | `hypervel.relationExistence` | +| `larastan.uniqueJobUniqueFor` | `hypervel.uniqueJobUniqueFor` | +| `larastan.uniqueJobUniqueId` | `hypervel.uniqueJobUniqueId` | +| `larastan.unnecessaryEnumerableToArrayCall` | `hypervel.unnecessaryEnumerableToArrayCall` | +| `larastan.unusedViews` | `hypervel.unusedViews` | +| `larastan.uselessConstructs.value` | `hypervel.uselessConstructs.value` | +| `larastan.uselessConstructs.with` | `hypervel.uselessConstructs.with` | +| `rules.modelAppends` | `hypervel.modelAppends` | + +## References and facts to preserve + +Local upstream: `/home/binaryfire/workspace/references/larastan` (`3.x`). Read its current source at implementation time; it is read-only. The port tracks . PHPStan source reference: `/home/binaryfire/workspace/references/phpstan-src`; the installed package is authoritative for actual API availability. + +| Reference | Why it matters | +|---|---| +| Larastan `bootstrap.php`, `src/ApplicationResolver.php`, `src/Concerns/HasContainer.php` | App/Testbench startup and runtime reflection; adapt supported Hypervel boot APIs rather than recreating them. | +| Larastan `src/Support/ConfigParser.php` | Uses PHPStan scopes and `NodeScopeResolver`, including imports and preceding statements. Preserve the full parser, recursion protection, lazy evaluation and dependency tracking. | +| Larastan `src/Properties/{ModelPropertyHelper,MigrationHelper,SquashedMigrationHelper,MigrationCache}.php` | Model schema inference and parsed-schema caching. `hypervel/schema-parser` replaces upstream's `src/SQL/` parsers. | +| Larastan `extension.neon`, `src/Rules/`, `src/LarastanStubFilesExtension.php`, `stubs/`, `tests/`, `e2e/` | Complete configuration, rule, stub and test inventory; do not infer coverage from documentation alone. | +| The components repo's `docs/todo.md`, Static Analysis and Container sections of `AGENTS.md` | Cancellation examples, lifetime semantics, unmatched ignores and intended eventual legacy removals. | +| `src/testbench/src/Foundation/{Application,Config}.php`, `src/testbench/src/Concerns/CreatesApplication.php` | `testbench.yaml`, `createFromConfig()`, discovery and the real per-process runtime skeleton. | +| `src/di/src/Bootstrap/GenerateProxies.php`, `src/di/src/Aop/ProxyManager.php` | Prepended proxy loader, original-source map, registry sealing, fingerprint reuse and atomic replacement. | +| `src/support/src/ServiceProvider.php`, `src/foundation/src/Bootstrap/LoadConfiguration.php` | Shallow top-level merging; named mergeable groups replace an entire matching entry. | +| `src/database/src/Eloquent/{Model,Builder}.php`, `src/database/src/Eloquent/Concerns/{HasAttributes,HasGlobalScopes}.php` | Scope registration can immediately call `extend()`; model construction, serialization, attributes and scope forwarding differ from simplistic substitutions. Local-scope discovery/dispatch lives on `Model`. | +| `src/translation/src/{Translator,FileLoader}.php` | JSON-first lookup, locale fallbacks, namespace overrides, extra paths and boot-added lines. | +| `src/foundation/config/queue.php`, `src/queue/src/{Queue,DatabaseQueue,CallQueuedHandler}.php`, `src/bus/src/UniqueLock.php` | After-commit defaults, transaction ownership, outbox and uniqueness behavior. | +| `src/coroutine/src/Coroutine.php`, `tests/Coroutine/CoroutineTest.php` | Cancellation chains, foreign/child boundaries and cancellation priority. | + +PHPStan 2.3.5's `MutatingScope::invalidateExpression()` and `invalidateMethodsOnExpression()` discard the call stack, so a callback loses its enclosing calls after any statement that invalidates an expression (`$order->status = 'paid'; $order->save();`). Rules read enclosing calls through `Support\CallStack::enclosing()`, an owner-approved temporary recovery that also reads each enclosing closure's creation scope (`getParentScope()`); its `@TODO` removes it once the minimum supported PHPStan preserves the stack, and `tests/PHPStan/Unit/Support/CallStackTest.php` covers the recovered frames. The upstream fix is a separate handoff (`_tmp/pr-ideas/phpstan-expression-invalidation-call-stack.md`). + +PHPStan documentation: [extension testing](https://phpstan.org/developing-extensions/testing), [rules](https://phpstan.org/developing-extensions/rules), [restricted usage](https://phpstan.org/developing-extensions/restricted-usage-extensions), [collectors](https://phpstan.org/developing-extensions/collectors), [result-cache metadata](https://phpstan.org/developing-extensions/result-cache-meta-extensions), [ignores](https://phpstan.org/user-guide/ignoring-errors), [configuration](https://phpstan.org/config-reference). Confirm APIs against the selected supported version before using them. + +## Package and repository wiring + +1. Use `src/permission/{composer.json,README.md,LICENSE.md}` as the third-party-port skeleton, preserving Larastan's MIT attribution. Set `type: phpstan-extension`, namespace `Hypervel\PHPStan\`, PHP `^8.4`, normal Hypervel support links and branch alias. The README uses `Documentation: https://hypervel.org/docs/static-analysis` for the page authored in `src/docs/static-analysis.md`, states deliberate public differences, and ends with `Ported from: https://github.com/larastan/larastan`. +2. Preserve the upstream package-root layout: `bootstrap.php`, `extension.neon`, `src/`, and necessary `stubs/`. Use existing `Methods`, `Properties`, `Reflection`, `ReturnTypes`, `Rules`, `Support`, `Types`, `Collectors` and `Concerns` directories. Put new model rules at `Rules/` root beside upstream's model rules; use `Rules/Coroutine` and `Rules/Validation` for those genuinely new domains. Do not introduce separate engines for Hypervel additions. +3. Declare direct production dependencies of the analyzer in its own manifest, even though consumers install the analyzer with `--dev`. Require `phpstan/phpstan-deprecation-rules`: included deprecation reporting is part of the supported installation contract, not an unrelated suggested tool. Check the latest compatible stable release on Packagist before adding it; the verified 2.0.5 release requires PHPStan `^2.1.39`, compatible with the planned analyzer range. Translate required Illuminate components to their actual Hypervel owners. Do not depend on `hypervel/components`, the entire framework metapackage, Head, Scout, private packages or every optional integration. Suggest `hypervel/testbench` for package analysis. Guard optional integrations using existing reflection/stub-extension mechanisms; preserve an absent-component installation test. +4. Add root autoloading and `replace` metadata for `hypervel/phpstan`. The root replaces the split package, so it must declare the analyzer's external dependencies itself; add `phpstan/phpstan-deprecation-rules` to root `require-dev` with `composer require --dev` at the selected compatible constraint. Use Composer commands for other root dependency changes; subpackage manifests without lockfiles may be edited directly. Do not add a runtime service provider just to register PHPStan services. Register them in PHPStan's container. +5. Require PHPStan `^2.3.5` in the root and analyzer manifests (Database and Foundation no longer use PHPStan directly once their extensions move), retaining current Larastan's supported APIs and DI registration. Use local identifier-specific ignores with adjacent code-comment `@TODO`s for confirmed PHPStan regressions: generic narrowing in `DataCollectionEloquentCast::set()` and factory native-return generics in Collection's `split()`/`chunkWhile()`. Do not add NEON ignores or `docs/todo.md` entries for them, alter correct runtime code/types, or add compatibility machinery for 2.3.1. Inspect new diagnostics individually and fix real defects before suppressing confirmed false positives. Check compatible stable versions for every added dependency. Add `hypervel/schema-parser` to the analyzer manifest with the first code that imports it; the root already replaces it. +6. The components repo has no extension installer: in both `phpstan.neon.dist` and `phpstan.types.neon.dist`, replace the four old includes with explicit `src/phpstan/extension.neon` and `vendor/phpstan/phpstan-deprecation-rules/rules.neon` includes, and update type-fixture scan directories. Check each skeleton/private consumer's actual installer and plugin configuration before choosing its loading mode; use discovery or an explicit include for each extension without duplication. Keep source analysis and the dedicated `types/` configuration distinct; do not run normal source analysis over `tests/`. +7. Update the base skeleton, other maintained skeletons and Testbench package examples to install `hypervel/phpstan` as a dev dependency and use the standard setup. Inspect each current manifest and analysis script; preserve existing additional Carbon/Swoole support where still necessary. +8. `bin/split.sh` discovers packages from manifests. Ensure the new package passes existing publishing checks; do not introduce a second publishing system. Prepare the split repository/Packagist setup instructions. Creating external repositories, publishing and pushing require owner authorization at the actual publication step. + +Example consumer configuration, without requiring the extension-installer plugin: + +```neon +includes: + - vendor/hypervel/phpstan/extension.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + +parameters: + level: 5 + paths: + - app +``` + +Use `src` for a package. Retain existing consumer levels unless a deliberate level change is separately justified; stricter package defaults do not require silently raising every project's PHPStan level. + +With an enabled extension installer, both installed packages advertise their own NEON files through Composer metadata; omit their manual includes. Without the installer, retain both includes above (adjusting paths for a custom vendor directory). Do not add a package-owned config loader to hide the second include. Deprecation diagnostics keep upstream identifiers such as `method.deprecated`; they are not renamed to `hypervel.*`. Use identifier-specific ignores with reasons for justified cases. To disable the whole deprecation extension, remove its manual include or, for automatic discovery, add `phpstan/phpstan-deprecation-rules` to the root Composer `extra.phpstan/extension-installer.ignore` list and regenerate discovery through Composer. That list disables automatic loading only; it does not cancel a manual NEON include. Document these standard mechanisms from the [deprecation package](https://github.com/phpstan/phpstan-deprecation-rules) and [extension installer](https://github.com/phpstan/extension-installer), without introducing a duplicate Hypervel switch. + +## Consolidate existing extensions + +Move implementations and their reusable signatures, update namespaces/imports and consumers, then delete old registration files and metadata. Preserve existing behavior and tests while merging additional upstream capabilities. Never register competing resolvers for the same method to conceal a missing merge. + +| Current files | Destination responsibility | +|---|---| +| Database `ForwardedBuilderMethodExtension`, `ForwardedModelMethodExtension`, `NamedScopeMethodExtension` | Central `Methods/`; merge Larastan builder/model/relation behavior and tests into one forwarding path. | +| Database `BuilderMacroResolver`, `ModelScopeMethodResolver`, `ModelScopeTypeResolver` | Central helpers beside the forwarding code; keep attributed/inherited scopes, generic substitution, variadics, named parameters and custom builders. | +| Database `ForwardedModelMethodReflection`, `ForwardedFluentMethodReflection`, `NamedScopeMethodReflection`, `ModelScopeParameterReflection` | Central `Reflection/`, keeping public parameter/return semantics. | +| Database `SoftDeletingMacros`; Scout `SearchableMacros` | Central analysis signatures; retain `eloquentBuilderMacros` metadata and optional trait detection. | +| Foundation `HelperFunctionReturnTypeExtension` | Merge into central helper-return extensions; preserve zero arguments versus explicit `null`, named arguments and unpacking for `response()`/`validator()`. | +| Head `WithHeadExtension`, `WithHeadMethodReflection`, `RoutingMacros` | Central optional routing-macro support without requiring Head. | + +Remove each old `src/{database,foundation,head,scout}/extension.neon`, old `extra.phpstan.includes`, analysis-only namespace references and obsolete PHPStan dependency declarations where the component no longer directly uses them. Recheck `src/`, `tests/`, `types/`, skeletons, scripts, documentation, and private consumers for every moved symbol. Keep runtime interfaces and correct native/PHPDoc types in their component. Do not leave forwarding aliases or duplicate files merely to preserve the old internal layout. + +## Bootstrap and runtime discovery + +- Port upstream app discovery and error reporting. Honor an explicitly bootstrapped application where supported, ordinary `bootstrap/app.php`, Composer's actual vendor directory, and the package/Testbench path. Do not hardcode this worktree or the components repo root into the published package. +- For package mode, `ApplicationResolver` calls `Bootstrapper::bootstrap($workingPath)` and boots `Testbench\Foundation\Application::create()` with the YAML's extra attributes and `discovers_root_package: true`. `createFromConfig()` would boot the raw configured `hypervel` path instead of Bootstrapper's disposable copy. Preserve providers, aliases, config, root package discovery, workbench configuration and the package's real Composer autoloader. Hypervel's YAML key is `hypervel`, not `laravel`. Reuse Testbench's disposable runtime and vendor-link actions; do not copy Larastan's obsolete manual symlink fallback or create a second skeleton lifecycle. `bootstrap()`'s optional argument names the directory to load `testbench.yaml` from instead of `resolveConfigurationPath()`, whose components-repo rule selects Testbench's contract-suite configuration (`src/testbench`, with `dont-discover: hypervel/components`, which drops the aggregate's providers the analysis needs); other callers keep the default. `Config::loadFromYaml()` records that directory (`getConfigurationPath()` on the concrete `Foundation\Config` only: provenance is the YAML loader's capability, not the contract's): a configuration an earlier bootstrap loaded stays cached, as in the analyzer's PHPUnit container. Do not force `enables_package_discoveries` (the YAML's `dont-discover` decides), define `TESTBENCH_CORE` (CLI mode boots a configured skeleton directly) or set `TESTBENCH_PACKAGE_TESTER` (it copies `.env` and changes `APP_ENV` handling). +- Boot once per PHPStan process, not per file or model. Parallel analysis boots independently in worker processes. Do not start HTTP/queue workers, run migrations, or probe backend services merely for analysis. Provider code can itself perform I/O; do not hide real boot failures or claim boot is free of side effects. +- Retain `BootstrapErrorHandler`'s clear CLI failure and nonzero exit. Adapt optional renderer integrations to supported Hypervel packages; do not add Whoops solely to preserve an unsupported Laravel integration. Do not continue with an empty container after a failed boot. Every failure gets one neutral title and tip list with the real exception and trace (upstream's `/vendor/` substring classification misreports custom vendor directories and path repositories), and exception text is escaped as literal console text. `bootstrap.php` also fails through the handler when there is no `bootstrap/app.php` and Testbench is absent (install message), or when `bootstrap/app.php` returns a non-Application; the consumer-installation fixture covers the missing-Testbench case. +- Document the actual CLI boot requirements: Swoole (not OpenSwoole), `swoole.use_shortname=Off`, and Swoole built with OpenSSL and native cURL support. `Application::checkEnvironment()` already throws specific corrective messages. Preserve those messages through `BootstrapErrorHandler` and test that they remain visible; do not duplicate the checks or add exception-message matching to manufacture another tip. +- Preserve runtime macro/reflection discovery, facade resolution, class-string/string-key container returns, configured authentication user types and console argument/option definitions. Keep interfaces honest: an arbitrary interface variable does not acquire implementation-only methods or macros merely because the container currently binds one implementation, so upstream's interface-macro branch is omitted. Explicit resolution is typed from the booted binding instead: container calls (below) and the factory helpers, reusing `AppMakeHelper`'s per-abstract resolution (memoized per extension). `response()`, `view()` and `validator()` return their factory only without arguments; `url()` and `logger()` for a null path/message, including explicit null; maybe-null and unpacked arguments give unions. So `response()->caps()` reaches a `ResponseFactory` macro and the documented `url()->full()` resolves, while a `ResponseFactory` parameter does not gain `caps()`. +- **Manager forwarding.** `Manager::__call()` forwards to the default driver, and custom managers rely on it without a mixin, so port `ManagersMethodsExtension` as a boot-profile fallback like explicit resolution. Native methods, `@method` tags and mixins (`ReflectionHelper::hasMethodTag()`) win. Otherwise the declared type of the booted manager's default driver, resolved once per manager specialization (misses cached), supplies its public methods through `ProxiedMethodReflection`: instance-only, because `Manager` has no `__callStatic()`; by-value parameters; driver-bound `static`. The driver is never built, because creators can need secrets, extensions or connections (building `JwtManager`'s driver without a JWT secret aborted the whole run). The checks below read the class the container binds the manager to, because a bound subclass does the forwarding; the requested (possibly specialized) reflection is used when it is that class or the manager does not resolve. The type follows `Manager::driver()`, using declared returns and never interpreting method bodies: + - a `__call()` not declared by `Manager` (Macroable, as in `ChannelManager`, or custom) forwards nothing; + - an overriding `driver()` (`SocialiteManager`) gives its declared return; + - otherwise `getDefaultDriver()` names the driver; null, or an `InvalidArgumentException` (no default, or missing config through `Repository::string()`), forwards nothing; `''` continues as in `driver()`; + - an overriding `createDriver()` (`ImageManager`) gives its declared return; + - a registered custom creator gives the overriding `callCustomCreator()`'s declared return (`SessionManager`), else the closure's own; an untyped creator never falls through to the named method; + - otherwise the method named by `Manager::getCreatorMethod()` gives its declared return, called on the booted manager unless overridden (then nothing is forwarded); + - an already-created default driver that the type rejects (`extend()` after resolution, which `driver()` ignores) forwards nothing; a compatible one never refines the declared type, so results don't depend on what boot happened to resolve. + + Upstream's driver construction, `StaticMethodReflection` wrap, non-public methods and per-lookup resolution are inherited defects. A name that studly-cases to nothing (`''`, `'-'`, whitespace) built the creator name `createDriver` and recursed into `createDriver()` without arguments (`ArgumentCountError`, as in Laravel); `Manager::getCreatorMethod()` excludes it and is shared by `createDriver()`, `ImageManager`'s pre-check and `SentinelManager::driverOrFallback()`, which now falls back for such names as documented. `MaintenanceModeManager` declares `@mixin MaintenanceMode` (all its drivers implement it). The docs state that forwarded calls are typed from the default driver the analyzed environment selects. +- **Container typing.** Boot registers facade aliases eagerly (`RegisterFacades`, unchanged), and PHP class names are case-insensitive, so keys such as `'config'` and `'request'` become class-strings that the declared `class-string` returns would type as facades. `AppMakeHelper` types constant keys from the booted container for `make`/`makeWith`/`makeTransient`/`get`/`resolve`/`factory` on the container contract (which also covers the Application contract, so upstream's `ApplicationMakeDynamicReturnTypeExtension` is omitted), the same `App` facade methods except `resolve`, and `app()`/`resolve()`. `factory()` wraps the result as `Closure(): T`. A non-constant or null abstract returns null, so the declared return applies (generic class-strings keep `TClass`; `app()`/`app(null)` stay `Application`). When resolution fails or returns a non-object, a key keeps its class type only when it names that class exactly (ignoring a leading `\`); otherwise, including unknown keys, unknown class literals, scalar bindings and alias keys, the result is `mixed`. An unpacked first argument is `mixed`: PHPStan reorders named arguments for extensions but does not expand unpacked ones. Explicit resolution is typed from the booted binding, so a driver the analyzed environment selects (the default queue or database connection, for example) decides the type; the docs state this boundary. Do not replace it with declared or alias-ranked types, which would lose an application's own bound implementations. The source fixes this exposed keep public APIs except one recorded Laravel difference: `CacheTokenRepository` accepts the cache contract that `CacheManager::store()` returns (auth README). +- Before changing AOP behavior, add a focused app/package subprocess fixture with an aspect, a class-map replacement, a provider registering a macro, and parallel PHPStan workers. Check original source diagnostics, inferred signatures and registration behavior. `GenerateProxies` seals registries and exposes `proxySources()`; `ProxyManager` reuses fingerprints and writes atomically. Parallel writes alone do not justify bypassing it. Preserve ordinary boot unless the fixture proves a conflict. Resolve a proven source-lookup conflict using the existing original-source mapping and PHPStan's supported facilities; no blanket disabling of aspects, custom AST rewriting, or speculative alternative class loader. If a supported API cannot preserve both semantics, bring the concrete failure and simplest alternatives to the owner before a capability change. + +### Package-mode boot and Composer installs + +These Testbench, Support and Foundation fixes make package analysis and Testbench itself honor the package's own metadata and actual Composer install: + +- **Root package discovery.** Add a `discovers_root_package` option (default `false`) to `Testbench\Foundation\Application::configure()` with a getter. The Testbench `PackageManifest`'s effective decision is CLI, package tester or that option. A child borrowing its parent's runtime (`Bootstrapper::isRuntimeCopy($base) && ! Bootstrapper::ownsRuntimePath($base)`) uses the parent's decision instead of its own CLI identity: `RemoteCommand` passes it per child like `TESTBENCH_RUNTIME_COPY`, as an `Env::encode()`d boolean only when borrowing (root-off stays an explicit false; non-borrowing and `serve` children get `false`, which unsets it). Without this, `remote('package:discover')` or a rebuild after `clear-compiled` would apply the child's policy to the shared worker manifest; the components repo's root declares 64 providers. +- **One root normalization.** Add one protected Foundation `PackageManifest` step applied to the raw dependency manifest at `build()` and at cached load; Foundation's default returns it unchanged. Testbench overrides it to add or remove the root entry (the full `extra.hypervel` block) for the effective decision, before ignore filtering, and writes the file only when the loaded root entry differs (including changed root metadata). This replaces the `write()` override, fixes the explicit-`build()` result that omitted the root from the same instance's `providers()`/`aliases()`, and keeps the file correct for plain Foundation readers: the skeleton `bootstrap/app.php` has no manifest swap, and `config:cache`/`route:cache` run its `artisan`. Cache the decoded root metadata lazily per instance; never persist a filtered result. Composer requires `name` only for published packages, so an unnamed root has no entry unless root discovery is on and it declares `extra.hypervel` metadata, which then requires a name; explicit empty names and malformed metadata still fail. +- **Manifest path.** `RefreshPackageDiscovery` deletes its manifest instance's `manifestPath`, honoring `APP_PACKAGES_CACHE`. +- **Vendor directory.** Add `Hypervel\Support\Composer::vendorPath(string $rootPath): string`: `COMPOSER_VENDOR_DIR` first, as Laravel's `PackageManifest` honors it (a relative value resolves against the root), else `{root}/vendor` when its realpath is a registered loader (no `installed.php` read), else the `ClassLoader::getRegisteredLoaders()` entry whose `{vendorDir}/composer/installed.php` root `install_path` realpath equals the root (as `InstalledVersions::getInstalled()` reads them), else `{root}/vendor`. Never select an unrelated first loader. A relative `COMPOSER_VENDOR_DIR` becomes its absolute path against the package root at two points: `Bootstrapper::bootstrap()` rewrites an inherited process value in `putenv`/`$_ENV`/`$_SERVER`, as `ServeCommand` exports `TESTBENCH_WORKING_PATH`; `CreatesApplication::createApplication()` rewrites one loaded from `.env`, YAML `env` or `#[WithEnv]` through the Env repository right after `resolveApplicationEnvironmentVariables()`, keeping that loader's putenv setting and restoration (covers TestCase, Commander and standalone/analyzer applications). Plain-skeleton children (`config:cache`/`route:cache`, `queue:listen`, Horizon, composer-package installers, Tinker) run a runtime copy's or custom skeleton's `artisan` and would otherwise resolve it against their own base path. No per-command vendor handling or skeleton-file hook. Every supported entry point loads the package's own install, so this covers project/global config and install-time env without parsing Composer configuration; runtime root metadata stays on `composer.json`, with no `COMPOSER` file-name handling. Use it in Foundation `PackageManifest` (application base), `TestStateRegistrars::forRootInstall()`, Bootstrapper's runtime vendor link, `UsesVendor`, `Commander` and the `hypervel_vendor_exists()` default. The Testbench `PackageManifest` uses `{basePath}/vendor` (the runtime link Bootstrapper selected, or a configured skeleton's own link); it must not resolve a package-relative env value against the clone. `ConfigCacheCommand`/`RouteCacheCommand` subprocess autoload and `TinkerCommand`'s classmap use the application's `PackageManifest::$vendorPath`; check the history of their remaining `$componentRoot` candidates and remove dead ones. Keep the existing skeleton vendor-link precedence and cleanup. Larastan's `Internal\ComposerHelper` is omitted: this resolver owns that job. +- **Root package and loader under a PHAR tool.** PHPStan's PHAR registers its own Composer install first, so `InstalledVersions::getRootPackage()` returns `phpstan/phpstan-src` and the first registered loader is the PHAR's. Add `Composer::rootPackage()`: the first `InstalledVersions::getAllRawData()` root whose `install_path` is not a `phar://` path, else `getRootPackage()` (an application shipped as a PHAR). `Composer::findLoader()` likewise prefers the first loader whose vendor directory is not a `phar://` path, else the first loader (`Application::inferBasePath()` already filters PHAR loaders). Use `rootPackage()` wherever the root package is read: the analyzer's `bootstrap.php`, `RuntimeInputs` (the vendor directory it excludes), `Bootstrapper::resolveConfigurationPath()`, `package_path()`'s default, `TestStateRegistrars::installedRootPath()` and Doctor's `DiagnosticSource::rootPackageName()`, keeping their failure handling. The exception renderer's class map uses `Composer::getLoader()` instead of the first registered loader (a deliberate divergence from Laravel). Tests: a prepended PHAR-keyed loader and a prepended install whose root is a `phar://` path. +- **Remote binary.** `remote()` runs `testbench_path('bin', 'testbench')` with `HYPERVEL_AUTOLOAD_PATH ??= Composer::vendorPath(package_path()) . '/autoload.php'`, preserving caller overrides. This replaces the `vendor/bin`, source-binary and PATH candidates, and works for custom vendor/bin directories, direct PHPUnit/ParaTest runners and path-repository installs (running the real script alone would load the components repo's autoloader through symlink-resolved `__DIR__`). `bin/testbench` loads `$_composer_autoload_path`, then `HYPERVEL_AUTOLOAD_PATH`, then its checkout fallbacks; correct the split-checkout fallback to `__DIR__ . '/../vendor/autoload.php'`. +- **Skeleton autoload entrypoint.** Move the skeleton `artisan`'s existing autoload lookup (`HYPERVEL_AUTOLOAD_PATH`, `{base}/vendor`, `getcwd()`, `TESTBENCH_WORKING_PATH`) into `bootstrap/autoload.php`, which currently has no callers, and require it from `artisan` as Orchestra does. Keep the precedence, missing-autoloader message and exit status; call no Support helpers before autoloading. The application skeleton's own `artisan` stays application-owned. +- **Tests.** Focused `Composer::vendorPath()` tests (absolute/relative env, a temporarily registered loader with its own `installed.php`, no match) plus child-process assertions for an inherited value (`BootstrapperTest`) and a YAML `env` value after application creation (Testbench `ApplicationTest`). Extend the existing Testbench manifest tests: cached file with and without the root entry under the option on/off, also read through a plain Foundation `PackageManifest`; explicit `build()` then the same object's `providers()`/`aliases()`; named/wildcard ignores on the root entry; a borrowing child's explicit build and missing-file rebuild under owner root-on/off; a cached borrowed read leaving the file unchanged. Add a `BootstrapTest` case with providers only in the package `composer.json`. Use the consumer-installation fixture with `vendor-dir: libs` and a custom `bin-dir`, including the path-repository setup, for main/worker discovery, relative `COMPOSER_VENDOR_DIR` through a real Testbench boot, `remote()` and a skeleton `artisan` child (`config:cache`), and package analysis without Testbench; keep existing skeleton-link ownership/cleanup tests. + +## Type inference and stubs + +Port every applicable upstream inference feature, not just the rules in the next section. Audit `Methods`, `Properties`, `ReturnTypes`, `ClosureTypes`, `Reflection`, `Types`, collectors and stub files against the corresponding Hypervel API. + +| Area | Required adaptation and coverage | +|---|---| +| Models | Native declarations and explicit PHPDoc take precedence appropriately; infer schema properties, relations, accessors, casts, separate read/write types, nullability, custom collections, factories and serialization shapes via `ModelSerializationDynamicMethodReturnTypeExtension`. Honor Hypervel attributes, visibility/appends and inheritance; Hypervel instantiates `CollectedBy`, so test a named `collectionClass:` argument and a parent's attribute. Without the attribute, `HasCollection::newCollection()` uses the static `$collectionClass`, so an overriding or inherited default below `Model` names the collection (its files tracked, as for `$builder`). Unknown schema knowledge must not erase declared properties or known relationships. A relation property whose declared relation is intersected with another type (an interface) reads the relation's `TResult`. A relation is a native method that the base `Model` does not declare, callable without arguments, whose return may be a `Relation`; one without a declared relation return (an untyped or `object` method, or such a resolver) reads `mixed`, never its declared return, and declaring the return and generics gives precision. A relation `resolveRelationUsing()` registers at boot is one too unless a native method takes the name: its resolver's declared return applies, read from the booted registry and never run. | +| Forwarded `when()`/`unless()` | `ConditionableExpressionTypeResolverExtension` also types calls a model or relation forwards to its builder (instance calls, and static calls on models); the builder comes from the forwarded signature's callback parameter. A model returns the builder's result. `Relation::__call()` returns the relation only for its own builder (`=== $this->query`), so `RelationConditionableCallbackExtension` gives the callback's builder a `ThisType`: fluent calls keep it and map to the relation, while a clone (`static`), another query or an opaque callable's result stays a builder alternative. No callback interpreter and no same-class-means-same-object assumption. | +| Factories | `ModelFactoryType` records whether a factory makes one model or a collection. Its count joins equality and subtyping (`describeAdditionalCacheKey()` keys PHPStan's comparison cache), so merged branches with different counts stay a union; an unrelated class stays `no`. `Model::factory()` follows `HasFactory`: an overriding `factory()` keeps its declaration; then an overriding `newFactory()`'s single concrete declared return; then a set static `$factory` default (its model hierarchy files tracked, as for `$builder`), then the model's own `#[UseFactory]` (`AttributeClassNameResolver`); an unresolvable selection keeps the TFactory declaration rather than a lower-priority choice; then TFactory's binding or the naming convention. A class-string receiver names its model, a receiver union in which a model lacks `factory()` is left to PHPStan, an unpacked count is unknown, and an unknown factory keeps the declaration instead of upstream's error type. A selected factory that declares its own templates is not count-tracked: it is `ClassReflection::getObjectType()`, keeping its arguments (a declared `static` read through the called model's type), or its defaults/bounds when named without arguments (`withTypes([])`), so its declared model-or-collection results apply and merged factories keep each model; static `new()`/`times()` decline it too, and the docs state this. Only `Factory`'s own methods change the count: static `new()` starts without one (a single model) and static `times()` with one (a collection), an instance `new()` clears it and `forEachSequence()` sets one. A factory's own `count()`, `times()`, `new()` or `forEachSequence()` gives an unknown count (a declared `static` would resolve to the receiver and keep the old one), its own `create()`/`createQuietly()`/`make()` keeps its declaration, and any other method keeps the count only when it returns the factory's own class. `createMany()`, `createManyQuietly()` and `makeMany()` clear the receiver's count at runtime; `FactoryCountResetTypeSpecifyingExtension` follows that in the null context (statements and assigned values), skipping call receivers. `@phpstan-self-out` is not used: PHPStan stores it on any receiver, so `User::factory()` would lose its count for the rest of the scope. A batch call nested in another call's arguments keeps the stale count (the docs give the assign-first remedy); no AST observer, alias tracking or runtime change for it. Dynamic `has*`/`for*` methods exist only for a model method whose declared return can be a `Relation`, as `__call()` reads its related model; `has*` takes count/state forms and `forEachSequence()`'s variadic arrays, `for*` the state form; `trashed()` exists only for soft-deleting models (omitted from `Factory.stub`'s class docblock, as upstream does) and takes the deleted-at value. `Factory.stub` adds `model-property` attribute and record types, including `createQuietly()`, and is unmapped in `StubMirrorsTest`. Runtime fixes: the batch methods return the model's `newCollection()`, and the coroutine-scoped `Model::withoutAutomaticallyEagerLoadingRelationships()` (`CoroutineContext`, checked after the boot flag) replaces `make()`'s toggle of the worker-wide flag and wraps the batch collections, matching Laravel's no-autoload result; documented under automatic eager loading. | +| Builder/relation forwarding | Preserve model generics, custom builders, relation-specific fluent returns, attributed scopes, SoftDeletes/Scout and macro callbacks; merge existing Hypervel capabilities with applicable upstream cases. **Builder selection** (`BuilderHelper`) covers two factories. `newModelQuery()`'s builder (`model-query-of`) is what the unscoped queries, `toQuery()` and relationship existence callbacks run (`has()` uses the related model's `newQueryWithoutRelationships()`: the `whereHas`, morph and `whereRelation` families, the builder side of `withWhereHas()`/`withWhereRelation()`, and the nested `where()` callback of `where()`, `firstWhere()` (the builder's and the relation stubs'), `whereNot()`, `orWhereNot()` and `orWhere()`, whose second argument makes the closure a query-builder subquery, so its callback type is a benevolent union): `HasBuilder`'s `@use` type, even when another trait's `newEloquentBuilder()` wins the conflict (Nested Set's `HasNode`); otherwise an override whose native return names a `Builder` subclass; otherwise `Model::newEloquentBuilder()`'s order: an override's declared return, a `#[UseEloquentBuilder]` `Builder` subclass (class, its traits, then parents; the argument read as instantiation evaluates it, including class constants), then the `$builder` default, which upstream ignores. `newQuery()`'s builder (`builder-of`) is what `query()`, forwarded calls, relationships, aggregates and `MorphTo` constraints run: `HasBuilder`'s `@use` type, otherwise an override whose native return names a `Builder` subclass, otherwise `newModelQuery()`'s, which `newQuery()` builds on. Only an override's native return is read: an undocumented override inherits the stub's `builder-of`/`model-query-of`, which would resolve back into selection, so a PHPDoc-only narrowing is not read, and an override declaring the base `Builder` (one calling its parent) keeps the factory it builds on. Inference limit: such a signature hiding a hand-built base builder beside a custom `newModelQuery()` builder reads as the custom one. Inference limit: Nested Set replaces existence queries with `newQuery()`; inference still uses `newModelQuery()` for their callbacks and preserves the supplied query type for direct existence calls. A model whose factories return different builder types can therefore receive an incorrect type. Inside a generic builder whose model is an open template, `model-query-of` gives the bound's builder (`Builder` for `Model`), because the model that runs the callback is not known there. A missing model class has no builder. The called model is bound through the builder's `setModel()` return, rebinding only the template mapped to `Builder`'s `TModel`. The Model stub types `query()`, `newQuery()`, `newQueryWithoutScope()` and `with()` as `builder-of`, and `newModelQuery()`, `newQueryWithoutRelationships()`, `newQueryWithoutScopes()` and `newQueryForRestoration()` as `model-query-of`; subclass overrides and other declared returns keep their declarations, so a callback's result, such as `withoutEvents()`'s, is not the called model's builder. **Dispatch** follows `Builder::__call()`: native methods; `macro()` registration, which returns null; local macros; global macros; named scopes (a real method only: a tag-only `@method scopeX` creates none); passthru; then query forwarding to native query methods, query macros, and any other `where…` name as a dynamic where. Eloquent discards forwarded results, except passthru, which returns the raw `toBase()` query with its declared rows. Forwarded methods are instance-only, because `Builder::__callStatic()` runs only the builder's global macros. It also accepts `macro()` and `mixin()`, which register global macros and return null, so `MacroRegistrationMethodReflection` describes both as static, without the query's closure binding: a local macro keeps its own `$this`. `MacroRegistrationExtension` binds a static `macro()` callback to the builder and returns the builder from an instance `mixin()`, which reaches the query's. Method existence must not depend on the model, or PHPStan reports a generic subclass's call as private, so `ModelPropertyColumnRule` reports a dynamic where's unknown column, with PHPStan's undefined-method message and identifier. Forwarded signatures name the model by the receiver's template, keeping late-static models. **Model dispatch** follows `Model::__call()`: after the native methods and the quiet increment family, a registered `resolveRelationUsing()` relation (no parameters, since the runtime passes the model; its resolver's declared return), then `through{Relation}()` when the model has a native `lcfirst(Relation)` method (typed by `through('relation')`), then builder forwarding to the builder's public methods: a protected one is unreachable, so the builder's `__call()` runs a scope of that name (named-scope precedence likewise counts only public native methods, on every host). Inference limit: on a generic builder receiver, PHPStan consults extensions with the builder's model unresolved when its class declares the called name, so a scope named like a protected builder method is not resolved there; a builder that fixes its model resolves it. `PendingHasThroughRelationship`'s `has{Relation}()` is typed by `has('relation')`. Prefixes are case-sensitive, as at runtime. `DynamicRelationMethodExtension` owns these calls; the forwarding and named-scope extensions defer to it, and the existence rule and property extension share `ModelHelper::getRelationResolver()`. Model, relation, named-scope and dispatch-method forwarding pass argument values, so a by-reference parameter is by-value there (see Macros). A non-union intersection resolves the model's scopes, then interface `scopeX` declarations. Through a relation, a builder method declared to return `static` or `$this` returns the relation, except `cloneWithout()` and `cloneWithoutBindings()`, which return the builder (the relation's own `clone()` returns the relation), and `applyScopes()`, which returns the relation or the builder (without scopes it returns the same builder, which `Relation::__call()` turns into the relation); relations reach global builder macros through their `@mixin`, and `QueriesRelationships` drops Laravel's circular `@mixin Builder`. Ordinary forwarding uses `getQuery()`'s return, intersected with `toBase()`'s while `toBase()` is `Builder`'s own; passthru uses a native `toBase()` override's return, else the ordinary type. Dependencies cover the model hierarchy's files for `$builder`, both query classes and a class constant a builder or collection attribute reads. **Uncertainty**: `builder-of`/`relation-of` give the declaring model's builder only without a path. A path, or a model alternative at any path segment, without a resolvable relation is unknown, since a subclass or `resolveRelationUsing()` can supply it, and is kept beside resolved alternatives. An unknown model has `Builder<*>`, `Relation<*, *, *>` or the base model's collection rather than upstream's `never`. The relation stubs' existence queries (`getRelationExistenceQuery()`, `getRelationExistenceCountQuery()`) return the builder they are given (a `TQuery` template); their parent query is a template bounded by the declaring model's `Builder`, because a plain `Builder` parameter rejects every builder for a relation of unknown models (`*`). `HasOneOrManyThrough::getRelationExistenceQuery()` bounds its parent query by the far parent's builder. **Collections**: only framework builder and relation implementations get the model's collection class; custom overrides keep their declarations. Paginators carry the stored collection as a third, defaulted `TCollection` template that `getCollection()` returns, `setCollection()` updates and `through()` widens. A model's static `all()` and builder-returning static calls resolve the receiver once: `static::`, `self::`, `parent::` and `$this::` keep the called model (`static`, never `$this`, since the call returns another instance; the class itself in a final class), as PHP forwards it and PHPStan types its own static calls, while a class name names that class. Upstream typed `self::query()` as the enclosing model; its assertion is corrected. Builder and relation `paginate()`-family returns name `EloquentCollection` in source, replaced with the model's collection; hand-built paginators keep the base collection, and upstream's model-value `getCollection()` conditional is omitted. `CursorPaginator` takes `array-key`-keyed input (`setItems()` renumbers) and keeps `TKey`/`TValue` required; a direct `new CursorPaginator(...)` is `CursorPaginator>`. `only()` keeps one key shape per first-argument alternative, a later multi-key argument makes keys optional, and an unknown key reads `mixed`. | +| Eager-load maps | One late-resolvable `EagerLoadMapType` types relation-keyed maps per key. Its method template takes the whole received array (`@template TRelations of array`), so each key's callback gets that key's relation, where upstream's stubs union the keys and accept swapped callbacks. Integer keys, non-constant portions, non-constant maps and keys covering no model keep Laravel's value type for their own entry; optional keys stay optional; supplied callback types never enter the expected type. Names, all analyzer-only:
- `eager-loads`: `with`, `withOnly`, `load`, `loadMissing`, `fresh`. String keys expect `(callable(relation-of): mixed)\|string\|array`; nested arrays recurse with the runtime's prefix (the key's name before `:columns`, then `.`). `Builder::with($relation, $callback)` keeps `relation-of` for its direct callback.
- `relation-aggregates`: `loadAggregate` … `loadExists`. String keys split the exact three-segment, case-insensitive `{relation} as {alias}` as `withAggregate()` does and expect `array{}\|(callable(builder-of): mixed)\|string`; a relation that does not resolve keeps Laravel's value type (its `Builder<*>` callback), as an unknown eager load gets Laravel's `Relation<*, *, *>`; no recursion (an array value is only the empty no-constraint form).
- `morph-eager-loads`, `morph-aggregates`: `loadMorph`, `loadMorphAggregate` … `loadMorphAvg`, `loadMorphCount`. Class-first, as `$relations[get_class($model)] ?? $relations[$model->getMorphClass()]` reads them: entry K covers class K when it is a model, and `Relation::getMorphedModel(K)` when K is that model's `getMorphAlias()` and the map has no key for it.
- `morph-type-eager-loads`, `morph-type-aggregates`, `morph-type-constraints`: `MorphTo::morphWith`, `morphWithCount`, `constrain`. Alias-first, `getMorphedModel(K) ?? K`, as `resolveMorphTypes()`; constraints expect `callable(builder-of): mixed`.
Morph lookups read the booted morph map statically (a shared runtime input, see Result-cache correctness). | +| Helpers and collections | Preserve conditional argument behavior, `Arr` shapes, `filter()` narrowing, higher-order proxy callbacks and generic collections. Compare inherited annotations before adding a stub. Keep Laravel's covariant collection `TValue` rather than upstream's invariant stubs: invariance rejects native `Collection` callback hints, `Collection` parameters given narrower collections and fresh-producer returns, and call-site `covariant` projections only cover read-only inputs. PHPStan 2.3.5's bleeding edge does not widen an already inferred covariant `TValue`, so upstream's bleeding-edge value-widening cases are recorded as `REMOVED:`; the docs give the `@var` remedy. PHPStan keeps a subclass's fixed template types through `static<...>` returns, such as `UserCollection` (`@extends Collection`) after `map()`, `groupBy()` or `chunk()`. This is a known limitation handed off for a PHPStan fix; the owner chose no package workaround. Retained upstream assertions of it carry a `Hypervel: known PHPStan limitation` comment, and the docs state it with the `@var` remedy. | +| Authentication | One shared guard-type helper serves `Auth::guard()`, `Factory::guard()` and `auth($guard)`, following `AuthManager::resolve()`: a booted custom creator's declared return (`ClosureTypeFactory`, never invoked; an overriding `callCustomCreator()`'s declared return instead), else the booted manager's `create{Driver}Driver()` declaration, else the declared return. It reads the container's bound manager: one overriding `guard()`, `resolve()` or `getConfig()` selects guards its own way, so guard types and user models keep their declarations; one overriding the provider selection (`createUserProvider()`, `getProviderConfiguration()`, `createEloquentProvider()`, `createDatabaseProvider()`) keeps the user models' declarations. As for disks, a call received by any class other than the `Factory` contract (which the container resolves to the bound manager), `AuthManager` or the bound class keeps its declaration: another implementation, or an unbound manager subclass, which has its own creators even when it overrides nothing. This covers `guard()`, the manager's forwarded `user()`, the users of a guard such a manager returned, and guard-macro forwarding (a facade by its booted root). `AuthGuardHelper::getUserModels()` owns user inference for the four user extensions. `viaRequest()`'s creator declares `RequestGuard`, so request guards are typed; an untyped creator keeps the declared return. Never claim `StatefulGuard` for an unmodelled guard: only `SessionGuard` is stateful, and token/request/Sanctum/JWT/Passport guards are not. An omitted, null or empty name is the union of every configured guard's type, because `shouldUse()` changes the default per coroutine; so is an unknown or unpacked name, because resolving an unconfigured name throws. A known set of string or enum names gives the union of those guards. `auth()` and `auth(null)` type as the booted factory concrete; unlike `guard(null)`, a maybe-null `auth()` argument can return the factory, so it keeps the declared conditional. PHPStan 2.3.5 resolves a conditional return from an unpacked list's element type, dropping the default branch when the list may be empty, so `auth()`, `trans()`, `__()`, and through `Concerns\InfersBoundHelperCalls` `url()` and `logger()`, share the owner-approved `Concerns\InfersUnpackedHelperCalls` (removal `@TODO`; handoff `_tmp/pr-ideas/phpstan-unpacked-arguments-conditional-defaults.md`): when every leading unpacked argument is a possibly empty list, the result is PHPStan's own result joined with the no-argument one (the booted factory for `auth()`); a definitely non-empty list keeps PHPStan's precise result, and string-keyed unpacks, other functions and methods keep PHPStan's behavior. Guard names come only from `guard()` on the factory or facade, the global `auth()` (resolved function identity) and `Request::user()`; enum cases map like `enum_value()` where the signature accepts enums. User models are provider-based inference: an Eloquent provider gives its model for any guard driver; a database provider gives `GenericUser` only for the stock session/token drivers (Sanctum accepts session-guard users when its provider has no `getModel()`). A guard without a modelled provider, or one whose provider driver has a custom creator, keeps the call's declaration. Preserve narrower declarations; manager and facade `user()` calls are typed only when they forward the `Guard` contract's method. Omit `AuthsMethodsExtension`: `AuthManager`'s mixins already forward guard methods, and contracts do not gain implementation methods. | +| Console commands | Argument/option types and the undefined-input rule read the booted kernel's commands (`$kernel->all()`: `withCommands()` discovery, `command()` closures and providers' `starting()` registrations; a standalone application is the fallback when the kernel cannot list them). Look up the call's receiver, not the enclosing class; exactly `Command` and `ClosureCommand` receivers keep the declaration, because they name every command or unrelated per-instance signatures. Merge each matched command's application definition once at lookup, as `run()` does, so bulk shapes include `command` and the global options regardless of analysis order. A name lookup is the union over matching commands that define it (missing ones throw), else the declaration; option names follow `Input`, so negations resolve to their option and shortcuts are not names. Aliases list a command once. A definitely-null key is a bulk lookup; overridden methods keep their declarations for typing and diagnostics. The rule skips a call whose same-receiver `hasArgument()`/`hasOption()` guard is definitely true or unreachable in scope, because the call never reaches the commands without the name; the components repo's `GeneratorCommand` and `CreatesMatchingTest` guard `--force` this way. Scope keys the guard as written, so check its positional and `name:` forms built from the getter's value, never the getter's own argument nodes. A receiver union with an alternative that has no known definition (e.g. `FooCommand|ClosureCommand`) keeps the declaration. `@param-closure-this` for `command()` lives on the Kernel contract and Foundation Kernel. | +| Views and translations | Retain `view-string` and live view-finder namespaces. Keep translation typing and the adapted missing-key rule. The optional unused-view rule reports absent statically discovered usage, not proof that a view can be deleted. | +| Custom PHPDoc types | Port `model-property`, `relation-of`, `builder-of`, `collection-of`, `view-string`, their parsers, templates and applicable tests. Analyzer-only annotations go in stubs. Where a valid stub cannot preserve the class contract (the collections and paginators, see Stub validation), they are PHPStan-prefixed source refinements (`@phpstan-param`) beside Laravel's ordinary `@param`. Plain PHPStan cannot resolve those types and falls back to the native declaration, also losing Laravel's `@param` detail, so the docs state that framework-aware analysis requires `hypervel/phpstan`. The eager-load map types and `model-query-of` are framework-internal, not documented public types. Unlike upstream, the late-resolvable types compose their position variance with invariant, so a `*` argument in a parameter resolves as it does in the generic class it describes instead of to `never`. A qualified `table.*` selector is accepted like `*`. `model-property` may be a supertype of an arbitrary string, as `accepts()` allows, because PHPStan rechecks an accepted intersection through `isSuperTypeOf()`. For a constant string, `isSuperTypeOf()` gives the model's actual property existence, so an abstract or template model is maybe rather than `accepts()`'s yes: union simplification would otherwise drop alternatives such as `'*'` from stub types. `relation-of` gives a strict union when several relations may apply, unlike upstream's benevolent one, because the callback receives whichever relation applies (README difference). `Eloquent\Collection::toQuery()` uses a return-type extension resolving `model-query-of`, since it runs `newModelQuery()` (an unknown model gives `Builder<*>`): the collection cannot be stubbed, and a source return refinement would fall back to the native `Builder` under plain PHPStan. | +| Stub validation | On a full analysis PHPStan validates stubs outside the project's vendor directory (this repo, and symlinked path-repository installs such as the owner's apps) against the stubs alone, and its errors cannot be ignored. Stubs therefore declare every class they reference: an empty declaration for a non-generic one (throwables with their real parent chain), and for a generic one its verbatim source class docblock, matching imports and native `extends`/`implements`, with no methods unless an analyzer override or validation needs them (the Query `Builder`, `ModelNotFoundException`, `Arrayable`). A class declared in a stub takes the nearest ancestor stub's PHPDoc for a method its own stub omits, including PHPStan's `ArrayAccess`/`IteratorAggregate`/`Countable` stubs (`PhpClassReflectionExtension::findMethodPhpDocIncludingAncestors()`), so a stub also declares, verbatim, each such method whose own source docblock that would replace (the Config and Cache `Repository` `offset*()` methods, with a diagnostic fixture for their key types). The support and Eloquent collections and the paginators cannot be declared: their verbatim docblocks fail validation (covariant `TValue` in `ArrayAccess`/`ArrayIterator`, accepted in source only through the `generics.variance` ignore), and editing them would replace the source declarations. Stubs therefore do not reference them: `BelongsToMany`'s `TResult` is `collection-of` (its results come from the related model's `newCollection()`), and the `paginate()` family and `pluck()` stay unstubbed, their returns coming from source through `EloquentBuilderExtension`/`RelationCollectionExtension`, while `ModelPropertyColumnRule` (enabled by `checkModelProperties`) checks their column arguments as the stubs' `model-property` types did, for builder, model-forwarded and relation-forwarded calls. Their eager-load parameters use source refinements (see Custom PHPDoc types). A stub class docblock replaces the source's, and a stub method docblock replaces the source method's, so a stubbed method repeats the source's native signature and every source PHPDoc tag besides its analyzer-only refinements. `StubMirrorsTest` compares an explicit mapping of the verbatim class docblocks (the declaration mirrors and the Eloquent builder/relation stubs except `BelongsToMany`) and mirrored method docblocks with source; stubs whose class docblocks intentionally differ, such as the facade mixins and `BelongsToMany`, are not mapped. Extension code reached while validating stubs must not assume project reflection, such as methods the stubs omit; an unreflectable provider counts as unknown writes, which also covers anonymous providers. | +| Dates and environment | Keep native/source `CarbonInterface` boundaries for configurable dates and exact immutable types for framework-owned values. Do not infer configurable dates from `get_class(now())`, or assume an `env()` default dictates the supplied environment value's type. `env()` and `Env::get()` declare `bool|string|TDefault|null` in source PHPDoc (`TDefault` from a value or `Closure` default; native signatures unchanged), matching `Env::getOption()` and `value()`; no stub or return-type extension. | + +Do not copy `ContractsMethodsExtension`, container ArrayAccess/dynamic-property support, Lumen paths, deferred-provider machinery, or unsupported Laravel integrations. Remove compatibility branches for old Laravel releases, but first merge applicable newer-version stub refinements into the single supported Hypervel surface. Deleting version directories without examining their overrides would lose features. + +**Macros.** Registries are read from the booted runtime: the registry of a `$macros` owner the application never loaded (`class_exists($owner, false)`), such as a class only analyzed files declare, is unavailable, so its macros are not claimed and analyzed files are never executed. One memoized lookup per class and method (hits and misses) builds macro reflections for closures, string and array callables and invokable objects (`Macroable::macro()` accepts `callable|object`; an invokable reflects as `[$object, '__invoke']`, keeping the callable's real name). Signatures and exceptions are resolved separately: + +- An `@method` tag on the receiver's or the macro host's ancestors (a facade's host is its booted root) and Head's `withHead()` signature take precedence over the callable, whose type `ClosureTypeFactory` reads from its declared signature only (`Request::validate()` keeps `array`, Builder's `whereCan()` its `$this`). Mixins do not count: a macro runs before mixin forwarding. +- `Macro`'s `ValidationException` map applies only to `Request` macros. `Methods/MacroThrowTypeExtension` (method and static) supplies a host macro's throw type for a tag-resolved method only when the tag's class has no native method of that name, has a registered macro with a throw type, and the reflection already has a non-null throw type (the magic handler's `@throws`); otherwise PHPStan's normal handling applies (`Http\Client\Factory::__call()` declares none). It looks up the macro on the call's receiver or resolved class (facades through their root) and returns the existing throw type when that host has none. +- Macro, facade, Storage, builder, model, relation and named-scope forwarding pass argument values: by-reference parameters become by-value through `ProxiedParameterReflection` (`ForwardedMethodReflection` rebuilds only a variant that has one, keeping PHPStan's extended metadata otherwise), `StaticMethodReflection` binds forwarded `static` returns to the target (not the facade), and native-method macros are callable statically and on instances, as closures are. +- `OptionalExtension` skips names with an `Optional` macro, because `Optional::__call()` runs macros first; this does not rely on extension ordering. +- `AuthManager::__call()` forwards to the current guard, which `shouldUse()` can set to any configured guard (`AuthGuardHelper::getDefaultGuardType()`; an unknown guard type claims nothing). A name is claimed when every configured guard has a public method of that name, natively or as a macro, and at least one supplies the macro; the combined guard method supplies the signature, static for the `Auth` facade and an instance `ProxiedMethodReflection` for `AuthManager`, which has no `__callStatic()`. This applies only where `AuthManager`'s own `__call()` handles the call (for the facade, its booted root); a subclass's Macroable or custom `__call()` keeps the ordinary macro lookup. Upstream's fixed `SessionGuard`/`RequestGuard` hosts claimed a macro the current guard may lack. Upstream's fixed Cache facade hosts are removed: `CacheManager` is not Macroable, and the facade's and manager's `@mixin Repository` already reach the store's macros. +- A `Builder` subclass that redeclares `$macros` has its own registry; upstream's extra lookup in the base `Builder` registry is removed. `AuthManager` forwarding applies to any facade whose booted root is the manager, not only `Auth`. +- Lookups whose result depends on a generic class's template types (`ReflectionHelper`'s tag-or-mixin caches, the manager extension's caches) are keyed by `ClassReflection::getCacheKey()`; class-wide facts (registered macros, facade roots, trait use) stay keyed by class name. + +**Requests, environment and storage.** + +- Request getters are typed in source with honest unions, not upstream's benevolent ones: clients control array input. Runtime fix: `query`/`post`/`cookie`/`server`/`file` take `mixed` defaults and return `mixed` natively, as Laravel accepts any default and merged or JSON values are scalars (the copied docblock types threw TypeErrors). PHPDoc: `query`/`post`/`cookie` keyed `string|int|float|bool|array|Stringable|TDefault` (`InputBag::set()`'s value contract, read through `all()`; a stored null gives the default), key-null `array` (`?0=x` and JSON list bodies give int keys, as do the file bag's `allFiles()`/`file()`); `server` keyed `mixed`; `header` keeps its native types, keyed `string|null` (a stored `[null]` header wins over the default; `bearerToken()` handles it) and key-null `array>`; `file`/`allFiles`/the conversion use `UploadedFile|array|null` (nested inputs; `UPLOAD_ERR_NO_FILE` is null), with `file()`'s default `(Closure(): TDefault)|TDefault` as `data_get()` evaluates it; keyed `route()` is `mixed` (bindings can be any value). Upstream's `InteractsWithInput`/`Request` stub typing, `RequestFileExtension`, `RequestRouteExtension` and `TestCaseExtension` (the source `mock()`/`partialMock()`/`spy()` conditionals cover it) are omitted. +- `RequestBridge` fills the request bag from a JSON body and shares it as the JSON cache, as `createFromBase()` and Laravel's `capture()` do, so `post()` matches between production and tests. JSON requests now decode once even when input is never read. +- `AbortIfFunctionTypeSpecifyingExtension` narrows the condition of the global `abort_if`/`abort_unless`/`throw_if`/`throw_unless`; a missing or unpacked condition declines. `AppEnvironmentReturnTypeExtension` is registered once, on the Application contract (extension lookup matches interfaces). It and the facade extension inspect every argument: none or only a definitely empty unpack gives `string`, a definite argument `bool`, otherwise the declaration. Runtime fix: `Application::environment()` reads its first value with `array_key_first()`, because a named `environments:` argument has no key 0. +- `FormRequestSafeDynamicMethodReturnTypeExtension` covers `FormRequest` and the `Validator` contract. Constant string keys give optional shape keys (`only()` omits absent keys); unpacked, dotted or wildcard, and non-constant keys decline. Source: `FormRequest::validated()` with a null key and the three `safe($keys)` annotations (contract, `FormRequest`, `Validator`) return `array`, because `Arr::set()` makes a top-level numeric rule key an int. Model inputs keep `array`: `create()` accepts the result at every level, and `fill()`/`update()`/the constructor report it only at level 10, truthfully (int keys reach `isFillable(string)` when nothing filters them). +- `Storage::disk()`/`drive()` and `FilesystemManager` receivers are typed per selected disk without building it; the `Factory` contract keeps its declaration and `cloud()` stays omitted. Upstream's fixed `FilesystemAdapter` is wrong for pooled (s3/gcs `ClientPooledFilesystem`, ftp/sftp `FilesystemPoolProxy`) and custom disks. One helper follows the bound manager's stock dispatch through declared returns, never method bodies: the `filesystems.disks` entry normalized by the private `prepareConstructionDescriptor()` (called in `FilesystemManager` scope); a custom creator gives `createDriverPooledDisk()`'s declared return for a poolable driver (the live poolable list), else the overriding `callCustomCreator()`'s or the closure's; poolable s3/gcs give `createClientPooledDisk()`'s, other poolable drivers `createDriverPooledDisk()`'s, otherwise the creator method's (creators carry truthful `@return` PHPDoc). Overridden `get()`/`resolve()`/`getConfig()`, an overridden `createScopedDriver()` for a scoped selection, a normalization exception, an unknown or non-constant name, and a cached or set disk the computed type rejects keep the declaration; an overridden `disk()`/`drive()` keeps its own. Constant names, enum cases and their unions are supported; null and `''` select the default disk. Results are cached per selection. A cached disk, like `ManagersMethodsExtension`'s cached driver, keeps the computed type only when that type definitely accepts the object's exact class (`->yes()`), so a parent-class instance declines. `StorageMethodsClassReflectionExtension` resolves a `Storage` facade call on the manager's native methods first, then on the default disk's type; the manager's declared adapter mixins (upstream's surface) apply only when that type is unknown, including when the bound manager overrides `disk()` or `__call()`. Checking the manager with its mixins first would shadow pooled disks' signatures (their `getDriver()` is `never`). +- Redis commands are case-insensitive at runtime (`RedisConnection::__call()` and `RedisProxy::__call()` lowercase the name after the exact-macro check), but PHPStan matches `@method` tags exactly. `RedisCommandMethodsExtension` covers `RedisConnection` and subclasses (and through the `@mixin` chain `RedisProxy`/`RedisManager`) and the `Redis` facade: a memoized lowercase index of `RedisConnection`'s own tags maps another casing to its tag, resolved through the receiver's normal lookup so the nearest declaration wins. It declines exact names, names outside the index (subclass tags and macros are never case-folded) and exact macros in the receiver's own registry (`static::$macros`; the facade uses the shared `RedisConnection` registry). Remove `RedisConnection`'s 22 case-only duplicate tags (identical signatures; keep the transformed-command block's entry) and regenerate the facade. The held connection's `pipeline` tag is `bool|Redis pipeline()`: only `RedisProxy`'s `MultiExec` takes a callback, and phpredis throws `ArgumentCountError` for one. Remove the `client(string $opt = '', ...)` tag: it shadowed the native `client()`, which returns the underlying phpredis client and ignores arguments (`$connection->client()->client('LIST')` runs CLIENT LIST). A connection's re-cased command is returned as a `ProxiedMethodReflection` declared on the receiver: PHPStan looks the called name up again on a method's declaring class (`ObjectType::getUnresolvedMethodPrototype()`), and `RedisConnection`'s macros can differ from a subclass's own registry. The facade keeps its own static tag. + +General type improvements go into source, with dedicated type fixtures and no runtime overhead. Confirmed examples include `Attribute` and the conditional `FormRequest::validated()` result. `Attribute`'s get callback input is a method template (`TValue`) on `__construct()`, `make()` and `get()`, so typed getters such as Laravel's `fn (string $value) => ucfirst($value)` are accepted; `make()`'s `TMakeGet`/`TMakeSet` default to `never`, so `make(get: ...)` resolves. The runtime bodies stay Laravel's. The owner approved declaration variance: `@template-covariant TGet`, `@template-contravariant TSet` and PHPDoc `@readonly` `$get`/`$set` (no native `readonly`), so a declaration may read wider and accept narrower than its callbacks infer (`Attribute` around a `non-falsy-string` getter). Reassigning a callback is reported (`property.readOnlyByPhpDocAssignOutOfClass`) and may be ignored with a reason; model attribute reads and writes are unaffected. PHPStan leaves the constructor's own template unresolved in the inferred class type (`string&TValue`); fixtures do not assert that leak. At max level, source analysis of `set()` reports `return.type` because a missing getter leaves `TGet` unresolved; the repo analyzes source at level 5 and callers get the declared type. Class template defaults (`= never`) would remove that report but also stop `missingType.generics` for bare `Attribute` returns, so they are not used. `Enumerable::keyBy()` already declares a new-key template (its bound includes `null`, which both collections key as `''`), and `Builder::make()`/`create()` already return `TModel`; add no stubs for these. PHPStan 2.3.5 does not inherit a parent's method templates into an override whose PHPDoc omits them, so they stay unresolved when the arguments do not infer them. The affected overrides (`Collection::keyBy()`, `LazyCollection::keyBy()`/`mapToDictionary()`/`crossJoin()`/`merge()`/`mergeRecursive()`, `Sentry\Hub::getIntegration()`/`withScope()`) repeat the parent's declaration with an `@TODO` to remove it once PHPStan inherits them. `duplicates()`/`duplicatesStrict()` cannot leak (a callback always infers their template) and the Eloquent collection's overrides declare their own types, so they repeat nothing. Analyzer-only `model-property` refinements on Model/Builder (including `array, mixed>` inputs) and `view-string` refinements on mail/view/route methods live in package stubs, not framework source. The Redis facade's tags and the `RedisManager`/`RedisProxy` mixins already type Hypervel's phpredis-only pooled surface: port the applicable phpredis assertions and add stubs only for demonstrated gaps. Omit Predis stubs, Laravel's client-selection branches and the `redisDriver` parameter; there is no supported alternate transport to select. + +Fluent methods keep Laravel's `@return $this` beside the native `: static` wherever the Hypervel body returns the receiving object: native `static` promises the class, not the same instance, so a custom builder declaring `@return $this` and returning a parent call otherwise fails. Check each tag against the method's return paths (clone and new-instance returns keep `static`); replacing stub docblocks repeat it. The tags cover the methods Laravel tags, their relocated Hypervel counterparts and Hypervel additions to classes using the convention. Laravel's tags on methods returning another instance (`Stringable::newLine()`, `withoutRelation(s)()`, `assertStructure()`, the `Mailable`/`Fluent` `__call()` macro paths) are not copied. Methods Laravel leaves native-only (AI, MCP, Head, JsonSchema and newer framework APIs) and Hypervel-only packages stay native-only; add a tag where a concrete typing failure needs the identity contract. The auth and notification traits (`MustVerifyEmail`, `CanResetPassword`, `RoutesNotifications`) declare no `email`/`email_verified_at` properties, which Laravel lacks: a trait tag overrides the model's schema and casts (`email_verified_at` lost its cast's write types). Truthful trait contracts stay, such as Nested Set's computed `depth` and relationship properties. `MustVerifyEmail` and `CanResetPassword` keep `@method notify()`, as Foundation `User` uses them without `Notifiable`. The `save()` family's options are `array{touch?: null|bool, ...}` in source and the `update()` stub (only `touch` is read; subclasses may read other keys); `transform()` takes `null|TValue`, as its callback runs only for a filled value; a single `upsert()` row's values are `scalar|object|resource|null` (PDO binds resources as large objects), so a list of rows keeps its column checks. The polymorphic relationship queries with a callback are stubbed with upstream's typing: `TMorphModel` from the `class-string`s in `$types` (a `TType of string` alternative keeps other strings from absorbing them) and the callback's `builder-of`, so an already-typed callback for another model is reported; `'*'` and morph aliases leave `TMorphModel` unresolved, which accepts any callback, and `RelationshipQueryCallbackExtension` still types inline closures from the relation for them. Stub validation needs the empty `BelongsTo` and `MorphTo` declarations this adds. + +Preserve upstream's Request and Optional `universalObjectCratesClasses` entries: `Request::__get()` resolves arbitrary input/route keys, and `Optional::__get()` forwards an arbitrary property name or returns null. These are supported dynamic APIs, not undefined-property mistakes. Do not add blanket crates for models or other classes to hide missing inference; use their dedicated reflection extensions and preserve explicit property types. Include the documented `$request->name` and Optional property access in typing coverage. Removing either dynamic API would require owner approval. + +## Configuration and schema analysis + +### Static configuration + +Port `ConfigParser` in full, then adapt its discovery and merging. Static expression analysis supplies config types, never the concrete values from the developer's `.env`. Do not execute provider or config code to manufacture facts, invent a runtime path registry, inspect captured closure variables, or use post-analysis collectors to feed inference. + +```php +// config/feature.php +return ['enabled' => (bool) env('FEATURE_ENABLED', false)]; + +// Inferred boolean, regardless of this machine's FEATURE_ENABLED value. +$enabled = config('feature.enabled'); +``` + +Upstream types only the application's files, claims an unresolved key holds its default (`config('logging.channels.sentry', [])` types as `array{}` while Sentry sets it), seals arrays that other sources extend, and reports every unknown `collection()` key. Replace that with one PHPStan type per config file key, composed from the sources Hypervel loads: + +- **Sources, highest priority first.** Application files: `configDirectories` when set (replacing only this discovery), else the booted `config_path()` overlaid by the Testbench workbench config directory when its config discovery is on (a workbench file replaces the same key). Then framework defaults from `LoadConfiguration::frameworkConfigPath()` when `shouldMergeFrameworkConfiguration()` is true. Then definite package merges. Keep upstream's per-file return-scope evaluation (imports, preceding statements, casts, `@return` docblocks) and its nested-file handling (a key with config files below it stays unknown). Unlike upstream, which reads the final composition, a config file's own `config()` reads see the configuration as it stands while that file loads: an application file sees the framework entries without an application file of their own and the application files before it in natural key order, with no merges or writes. A file's own key is unset until it returns. Reads inside a function or closure body keep the declared type, since the body may run during loading or long after. Framework and package files' reads keep the declared type: framework files load into an empty repository and providers merge package files in an order the analysis does not follow. A package's analysis (the Testbench boot, which the bootstrap records as `RuntimeInputs::isPackageApplication()`) leaves package merges unknown, because the applications using the package replace them: `composeConfig()` widens each one as it does a merge that may not run, so the Testbench application's, workbench and framework entries stay typed and package-only keys are unknown, not absent. This matches upstream, which never types package merges. Upstream's config-file `@return` docblock is not the consumer contract: it must describe the whole file and nothing checks it against the values. It applies regardless of `treatPhpDocTypesAsCertain`, unlike upstream: that setting only relaxes always-true checks, and PHPStan still infers from PHPDoc when it is off, as the components repo and its consumers configure it. Never infer a source as unknown from which paths a run analyzes. + - Loader fix: both `LoadConfiguration::loadConfigurationFiles()` copies (Foundation, Testbench) re-set every residual base entry after the app files load, discarding nested files below a framework-only name (`config/services/stripe.php` without `services.php`). Skip a residual entry that is base-only and identical to its initial value; added or changed residual values still apply. This preserves `loadConfigurationFile()`'s returned residual-value contract, not an override's direct repository write to a base-only key, which the old loop reset. Test the nested case and one override adding and changing residual values, in both copies. Then remove `ConfigParser::composeConfig()`'s special case that models the overwrite. +- **Merging.** Follow the runtime: `array_merge` at the top level and at each mergeable option (string keys: the higher source wins; integer keys: appended and renumbered), and `replaceConfigRecursivelyFrom` replacing by key at every depth. A higher array with unknown keys (an unsealed `@return` shape) may replace any lower entry it does not list, so in both merges those entries become unknown. A definite package merge reads its target's presence as trinary: a definitely absent key takes the package's values, while a key that may be present (optional, or unlisted in an open array, whose missing intermediates stay open) is unknown, because the application's values win only where it set them. Read mergeable options once per instance and key from the real `mergeableOptions()` of a `LoadConfiguration` and of the booted provider; do not copy their tables. +- **Provider merges.** Find `mergeConfigFrom()`/`replaceConfigRecursivelyFrom()` calls from the cached AST of loaded providers' class hierarchies and traits, excluding `ServiceProvider`. PHPStan scopes do not fold `__DIR__` (`usePathConstantsAsConstantString` is off, and must stay a consumer setting), so resolve arguments with php-parser's `ConstExprEvaluator`: the source file's `__DIR__`/`__FILE__`, global `dirname()` with constant arguments (bound by name; unpacking is not constant), class constants, and the current string value of a `self::`/`static::` property that is provider boot metadata (Sentry's documented boot-only `$abstract`). This is metadata argument resolution, not expression execution or runtime value typing. A merge is definite when its arguments resolve and it lies on the straight-line path from the effective (most-derived) `register()`: top-level `parent::register()` and `$this->helper()` statements followed under an active recursion guard (a helper called twice runs twice), with no earlier `return` outside nested closures. An earlier `throw` cannot have run in a booted application. Boot-time merges, merges of overlapping keys by different providers and every other resolved call are candidates; resolving arguments does not prove execution. Enumerate the registered providers once (`getProviders(ServiceProvider::class)`). Creating a callable runs nothing: `$this->helper(...)` is not followed, and a callable created from a merge method is an unresolvable merge. A resolved file is a package config file even when its key is not. +- **Mutations widen the composed type.** Direct writes come from the keys of the array entries `ConfigMutationTracker` records after LoadConfiguration attaches it, read once through the analysis-only `(fn () => ...)->call()` pattern. These cover Testbench's testing connection, user model and database/queue rewrites and provider `set()`/`push()`/`prepend()` calls. Never read the recorded values or run or inspect the recorded closures. `configureUsing()` writes are not recorded, so scan closure callbacks in provider hierarchies, from the cached AST, for writes through a recognized repository receiver (`set()`, `push()`, `prepend()`, offset assignment or unset). Recognized receivers, by resolved function and class identity: the callback's parameter, a method parameter typed as the repository contract or concrete, `config()`, global `app()`/`resolve()` with `'config'` or the repository class, `$this->app->make()`/`get()` with the same ids, plus `config([...])` and Config facade writes. The container has no `ArrayAccess`, so there is no `$this->app['config']` form. Known reads (`get()`, `has()`, the typed getters, `collection()`, `all()`, `getMany()`, offset reads) are not effects. Passing, reassigning, capturing or returning the callback parameter, calling any other method on it, or a non-closure callback is an unlocalizable write; an unrelated `app()`/`$this->app` use is not a write. Bind arguments by their real parameter names (`key`, `offset`, `abstract`, `name`, `id`) with `getArg()`, never by position, since the provider AST is not PHPStan's reordered call; an unpacked key is unlocalizable. Creating a callable from `config(...)` or a repository method writes nothing; inside a callback it is an unlocalizable write, since its later calls cannot be followed, unless it comes from a read-only method. + - The same pass records constant keys of direct writes through recognized receivers anywhere in the provider class-likes, covering branches the CLI boot did not run. The tracker stays authoritative for executed writes, so unrecognized flows (locals, helpers, other services) are not treated as writes. This is a documented best-effort boundary: an unexecuted write through an unrecognized flow is not found. + - Resolve a write key fully first. Otherwise bound it by its constant leading part (concatenation or interpolation) up to the last proven dot boundary: `'filesystems.disks.disk_' . $name` writes `filesystems.disks`, `'filesystems.disks' . $suffix` writes `filesystems`. Without a complete segment it is an unlocalizable write. One helper serves callbacks and direct writes. + - A replacement at P makes lookups at or below P unknown and widens P to optional `mixed` in its ancestors. + - A candidate merge at K unseals K and its string-keyed mergeable options, keeping their known fields. Numeric merge levels and non-array ancestors, which `Arr::set()` replaces, are replacements. + - An unresolvable merge target or an unlocalizable write makes every lookup unknown. + - Mergeable options are named-entry registries that framework code extends at runtime, so they stay unsealed. An unsealed tail covers additions, not replacements: HandleExceptions fills a falsy `deprecations` or `null` log channel inside a recorded closure, so a registry entry whose known value may be falsy is unknown. Other known entries keep their exact types. +- **Lookup.** Walk offsets with PHPStan presence (`hasOffsetValueType()`). Present gives the value alone, so a default cannot hide an incompatible value. Maybe gives the value or the default. Absent gives the default, or null without one. Unknown keeps the declaration. Evaluate only `Closure` defaults to their return, as `value()` does: the default itself or its union alternatives, never a Closure it holds. Cover `config()`, the Config facade's `get()`, typed getters and `collection()`, and the repository contract (which also covers the concrete repository), only for methods the contract, the concrete repository or the facade declares. A custom implementation or override keeps its declared type, and the rules skip it too. On a repository receiver this includes what a getter delegates to: the typed getters read through `get()` and `collection()` through `array()`. Facade calls forward to the bound repository, so only the called facade method is checked. Typed getters intersect their native type with the result, and an unknown `collection()` keeps its declaration. During recursive lookups an in-progress discovery is unknown and is not cached; publish derived caches only after discovery completes. + +Retain `configDirectories`, `viewDirectories`, `translationDirectories`, migration/schema paths and associated upstream settings for retained features. Prefer boot-discovered paths by default; document exactly whether an explicit setting replaces or supplements discovery. Larastan's configured migration/schema paths replace its fallback paths; preserve that contract unless a separately justified adaptation changes it. A non-empty `viewDirectories` likewise replaces the booted finder's paths and namespace hints, for the unused-view rule only; the missing-translation rule always scans the finder's paths, as upstream does. By default the unused-view rule names root views from the finder's paths and scans namespace hint directories for usages only: framework and package views such as `pagination::tailwind` are rendered by names no literal call shows. + +### Model schemas + +Port migration aggregation and `MigrationCache`, covering MySQL, MariaDB, PostgreSQL and SQLite without a live database connection. Dumps and literal migration SQL are parsed by `hypervel/schema-parser` ([docs](../../src/docs/schema-parser.md)), with the parser chosen by the connection's driver. Dialect parsing belongs to that package; the analyzer maps its results to PHP types. Real defects this work exposes in it are fixed in that package on this branch, through the normal second-opinion and review workflow. Package schemas and schemas owned by other services can be supplied through the retained path settings. Fixtures are real `schema:dump` output for each driver, generated from the fixture app's migrations. + +Default migration discovery follows `migrate`: the booted migrator's `paths()` (providers' `loadMigrationsFrom()`, which may name files), then `database_path('migrations')`, as `BaseCommand::getMigrationPaths()` orders them, read through `Migrator::getMigrationFiles()`: top-level `*_*.php` files, a later path's file replacing an earlier one with the same migration name (a published migration replaces its package's), applied in name order. A non-empty `databaseMigrationsPath` replaces both and keeps upstream's recursive `.php` scan, ordered by file name; `disableMigrationScan` still applies. One resolved list feeds discovery and dependency tracking: named files as file dependencies, directories with their discovery pattern. + +Runtime fix: `DumpCommand` resolves its connection through `Migrator::resolveMigrationConnectionName()`, as `MigrateCommand::loadSchemaState()` does, so `schema:dump` runs on and names its file after the routed migration connection (the shipped `pgsql-pooled` routes to `pgsql`). Test the routed connection and default filename with default and explicit selection. + +Runtime fix: SQLite fetches a JSON number from a `json`/`jsonb` column as an int or float, which the strict-typed `json_decode()` rejected. `Casts\Json::decode()` returns such a number unchanged after the custom-decoder branch (which still receives the raw value), so every JSON cast reading through it works; `HasAttributes::fromJson()` and `jsonIsEquivalent()` accept `string|int|float|null`, as Laravel's untyped methods do. Covered by an SQLite hydrate, read and dirty-check regression and decoder tests. + +- **Identity.** + - Facts belong to an owner: `Migrator::resolveMigrationConnectionName()` of the connection's `ConnectionName::parse()->base`, so a pooled model and its migration connection share facts. A config error the method rejects gives unknown identity. + - Facts are keyed by owner, schema (or none) and physical name: the requesting connection's `prefix` applied once to a logical name's final segment, as `Grammar::wrapTable()` does. Dump and raw-SQL names are already physical. + - Each side uses its own connection's effective config. Model lookups and fetched types use the model connection's. A migration statement (Blueprint or raw) uses its explicit `Schema::connection()`/`DB::connection()` name, which is not redirected, else the routed migration connection (`Migrator::runMethod()`). + - Connection names are evaluated, never executed: string and null literals (empty selects the default), the migration's `$connection` property, a `getConnection()` override that returns one expression, `config('key'[, default])`, `config()->get()`/`string()` and the `Config` facade's with a literal key, and `??` of these, reading only the side that runs. The migration's own `getConnection()` and `$connection` are read from its class, inherited ones only when `Migration` declares them; one a used trait declares (nested traits included), a trait alias names or an unknown trait could supply is unknown, as for methods (below). A `$connection` that a method other than `down()` assigns (also by reference or compound assignment), or that a promoted constructor parameter sets, in the migration or in code it inherits or uses from traits below `Migration` (an unreadable class or method counts as a write), is unknown; constructors are never run and assigned values never interpreted; an explicit literal selection stays known. Config reads use the booted repository and keep a present null distinct from a missing key; `string()` and a `string` return type are known only for a string. Anything else is unknown, as is a kept first-class selector callable. Passport's and Telescope's published migrations are the reference cases. + - Effective config is deterministic: `parseConfig()`, then per-record `configForRead()`/`configForWrite()` as `DatabasePool::roleEndpoints()` does, never the `Arr::random()` selection. A `::read` name uses its read records; a base name uses read and write records, because reads can use either PDO. A `DB::extend()` connection uses its parsed config and reads `mixed`. Driver, prefix, `database`, schema/search path and username must agree across records, otherwise that connection's lookups are unknown; fetched types are the union over the distinct option sets. + - A dump is `{connection}-schema.{sql,dump}`, for a booted `database.connections` key, in a `squashedMigrationsPath` directory. `.dump` wins over `.sql`, as in `MigrateCommand::schemaPath()`. + - Any other name keeps unknown identity: document the naming requirement and never assume the default connection. + - Index tables, views, and PostgreSQL enums and domains by owner, schema (or none) and name, keeping unqualified definitions distinct. Never use the package's unqualified lookups, which throw on ambiguity. + - MySQL/MariaDB: an unqualified definition belongs to the connection's configured `database` (`schema:dump` writes no `USE`, and unqualified DDL targets the current database). SQLite definitions carry `main` or the attached database's name. + - PostgreSQL tables: an unqualified model table resolves through `PostgresBuilder::getCurrentSchemaListing()`'s precedence (`search_path`, then `schema`, then `public`; `$user` is the configured username), read from config only, taking the first schema with a qualified definition. An unqualified definition (from Blueprint or literal migration SQL) records the defining connection's resolved path and matches only a model connection with the same path, and only when no qualified candidate exists, because unqualified creation lands in the first existing path schema. An unqualified definition beside a qualified candidate, or a qualified model table with only an unqualified definition of that name, is ambiguous. + - PostgreSQL types: an unqualified type name resolves through the same precedence, with `pg_catalog` searched first unless the configured path names it explicitly, as PostgreSQL does. A name that resolves to no known built-in or dump-defined type reads `mixed`. A domain's unqualified base that names a user type reads `mixed` unless the effective path is `pg_catalog` then one schema, because a type of that name created later in an earlier path schema would shadow the one the domain was created with; a domain chain that revisits a domain (only after failing migrations that drop without `CASCADE` and recreate) reads `mixed`. A type fact can be uncertain (`null`: its definition or presence is unknown); reaching one reads `mixed` without searching later schemas. Domains resolve through the type facts, never through the base the parser embeds in a column, and an array (including an array of a domain) reads as its text representation first. +- **Completeness.** + - A connection is incomplete when its dump cannot be read, throws `ParseException` (the PostgreSQL parser rejects `PGDMP` custom archives), has schema-level issues, or its driver has no parser. A dump that is not interpreted (unreadable, unparsable or without a parser) also makes the owner's other dump facts unknown, since it may define any relation they define. Schema-level issues also make that input's tables incomplete and its types uncertain, as for raw SQL. A relation that two dumps of one owner both define is unknown; a type they define differently is uncertain, and a later dump does not restore it. An owner with no schema source (no dump and no interpreted statement) is unknown, not proven empty. + - A table is incomplete when it has issues, is a view (no column facts), is ambiguous, or is affected by the migration-side cases below. + - Known columns keep their types and casts unless a migration-side case makes them uncertain. An incomplete dump table that the same dump alters (`Impact::$relations`) is unknown: the parser could not apply that alteration, so a kept column may be gone or retyped. An unknown column on an incomplete table or connection gets no missing-column claim, while complete tables still report one. + - With no established facts for the model's table, an undeclared name is maybe an attribute: the property extension defers to a declared relation (or its `_count`; a relation method named exactly like a count wins over the count) before assuming one. A model runtime reflection cannot instantiate has no established facts either. Neither has an abstract model (including `Model` and template bounds), but the property extension does not claim its names: a model trait's `@mixin Model` (Scout's `Searchable`) would then give every model every property. `ModelPropertyHelper::acceptsPropertyName()` instead accepts any name for an abstract model, evaluating union alternatives separately (intersections whole) and keeping the concrete alternatives' results; `GenericModelPropertyType::accepts()`/`isSuperTypeOf()` and dynamic `where{Column}` use it. Only a complete known schema rejects a column. A known cast key is an attribute without a column, as `HasAttributes::hasAttribute()` accepts it; undetermined method casts do not make every name one. +- **Uninterpretable migrations.** A construct the aggregator cannot interpret makes the facts it could affect unknown, types included: an unknown Blueprint method (macros), a helper or other escape of `$table`, a non-constant column name in a `change()`, loops and other control flow make the table unknown; an added column with a non-constant name makes the table incomplete, keeping its known columns; an unknown table argument makes the owner unknown; an unknown connection, or a migration file that does not parse (upstream skipped it), makes every owner unknown. Do not build a PHP interpreter. + - Facts come only from code applied as executed: `up()`, the methods it calls on `$this`, `self`, `static` or `parent` (`$this::` as `static::`; after their arguments, which are searched), Blueprint callbacks (including `after()`), `withoutForeignKeyConstraints()` and `whenTable(Doesnt)Has(Column|Index)()` (the column guard is decided like `hasColumn()`; index guards join both outcomes). Other code, including closures and helpers in nested positions, is searched for Schema and DB calls, which only make their targets unknown. A first-class callable created as a statement runs nothing; a kept one (assigned or passed) makes its targets unknown, since it can run later with any arguments. A callable array naming one of those methods (`[$this, 'm']`, or `self::class`, `static::class` or `parent::class` first) is searched the same way, as the equivalent call with the same dispatch. A dynamic method name on those receivers, including a callable array's non-literal name, makes every owner unknown. Callable variables and class-string callables are not followed. A dynamic method name on a recognized Schema receiver (the facade, a selector or a builder variable) makes that connection unknown (every owner when the connection is unknown). Code that cannot be read (an unreadable or dynamically named method, an unparsable migration file) and a dynamic Schema method also make the morph key type unknown; an unknown connection alone does not. Dynamic names on `DB` calls stay within the query-chain boundary below. Arguments of recognized Blueprint and Schema calls are read only for the supported values described here, not searched for other schema changes; statements in other contexts are searched as above. + - `up()` and those methods may be inherited from a parent migration or a trait. They are found through reflection, parsed from the declaring file with the migrations parser and dispatched as PHP does: `$this`/`static::` on the migration, except that `$this` reaches a private method of the class declaring the calling body first (PHP binds private calls to the calling scope); `self::` on that class, `parent::` on its parent. The files of the migration's parents and traits below `Migration` (`SourceFiles::forClass()`, recorded once per migration) are sources of every fact, because an edit to any of them can change which method runs, or whether inherited code sets the connection, while the selected body's file stays unchanged. Recursion is detected per declaration, so a method calling its parent's same-named method runs both. `Migration`'s own methods change no schema. A method whose body cannot be read (an unknown class, an unparsable file, a trait use with conflict resolution or aliases, including an alias that supplies the called name) makes every owner unknown; aliases are not resolved. Bounded method lookup only: no call graph across other objects and no argument-value interpretation. + - Assigning a Blueprint's `engine`, `charset` or `collation` property sets a table option and keeps the table's facts; the assigned value is searched like any expression. Other Blueprint property writes escape. + - `removeColumn()` only cancels the definitions its Blueprint queued, so its column becomes uncertain (never a drop). A non-literal `unsigned` makes only that MySQL column's type unknown. + - `addColumn()`'s literal parameter bag applies over its type and name, as `Blueprint::addColumn()` merges it, and later fluent modifiers win; a non-literal bag makes the table unknown. A `type()` modifier replaces the column's type (a non-literal one makes it unknown) and drops enum allowed values. On a `foreignId*` column, `constrained()`/`references()` return a `ForeignKeyDefinition`, so only the modifiers inside the innermost such call apply to the column; on any other column they are Fluent attributes and the chain stays the column's. `fill()`, `set()`, `offsetSet()` or `offsetUnset()` in the column's chain can set any attribute, including its type and nullability, so it makes the table unknown; so do calls chained after `removeColumn()`, which returns the Blueprint. + - Rename, drop and `change()` targets match column names as the driver does: ignoring case on MySQL/MariaDB/SQLite (keeping the stored spelling; an ambiguous match makes the table unknown), exactly on PostgreSQL. A `change()` naming a stored column in another case makes the table incomplete, since the resulting spelling depends on the server. + - Builders in local variables, without tracking data flow: per applied method or closure (cached per function; an arrow function shares its parent's), a variable holds a builder when every write is an assignment statement of a Schema selector (`Schema::connection()`, `DB::getSchemaBuilder()`, `DB::connection()->getSchemaBuilder()`), all selecting one connection, and every other use, nested functions included, calls a method on it. Its calls are followed like `Schema::` calls. Any other variable assigned a Schema or DB selector escapes: the selector and every later use of the variable make the selected connection unknown (every owner when unknown). A Schema selector not consumed by a recognized call or builder variable, and a `DB::connection()` that is not a method receiver, also escape. Query chains on a DB connection (`DB::connection()->table()->update()`) keep facts; this is the supported query-chain boundary, not proof that no method call changes the schema. A DB connection stored in a variable escapes even when its later calls only update rows: a known precision loss, not an unsupported pattern. Aliases of aliases, builders reached through properties, and statements on connections reached other ways (`$this->db`, `app('db')`) are not followed; the docs state these boundaries and keep them distinct from followed Schema-builder variables. + - PostgreSQL inheritance and partition descendants change with their parent (Blueprint DDL has no `ONLY`; dropping a partitioned table drops its partitions). Every Blueprint alter, drop or rename and every raw-SQL impact relation also makes the known descendants unknown, found through the parsed `parents`/`partitionOf` links. Regressions: a dump with a parent and child followed by a raw `ALTER COLUMN TYPE` on the parent, and a Blueprint `Schema::table()` on the parent. + - `if`/`elseif`/`else`/`switch`: apply each outcome, including the fallthrough, to cloned state and merge at each boundary. A case body ends at its first top-level `break`; a case that can leave the switch elsewhere (a nested `break`/`continue` whose level passes the loops and switches between it and the case; function and class bodies excluded) makes the switch's affected facts unknown, as a loop does. A column or table absent from any outcome becomes uncertain (`mixed`, no claim; a missing attribute reads null). An identical `ColumnType` in every outcome keeps its type with nullability ORed; differing types read `mixed`. A user type that one outcome lacks or defines differently is uncertain. + - `Schema::hasTable()`/`hasColumn()` guards (also through a selector or builder variable, negated or compared with `true`/`false`) are decided only from established facts of a known owner and complete table, ignoring letter case as the framework can; otherwise the condition is unknown. An early `return` ends its outcome for the rest of that body. Each body keeps at most a continuing and a merged returned state. + - Blueprint commands run once the callback returns, while the aggregator applies them at once. A guard inside the callback on the callback's own table (same owner and a current lookup giving its key) therefore reads a copy saved when the callback starts: the resolved table for `table()`, the previous table at the key or none for `create()`. The copy is used only while the current table is complete; an immediate change such as a nested `Schema::table()` makes the guard unknown. A queued drop or rename, which the facts already apply, removes the callback's table from its key, so every guard on that owner in the callback is unknown, whatever name or connection it uses; no name-reachability model. With no saved table, `hasTable()` is false only under the absent rule (complete owner, no case variant besides the callback's key). An ambiguous lookup stays unknown, and the saved and current tables' sources are recorded. +- **Raw migration SQL** (`DB::statement()`/`unprepared()`, directly or through `DB::connection()` or `Schema::getConnection()`). Each parse starts empty, so its snapshot does not describe changes to the accumulated schema; the parser's `Schema::$impact` summary does. Each relation it lists (altered, dropped or renamed, including both rename names, whether or not the input also defines it) becomes unknown, and `unknown` makes the owner's earlier facts unknown and skips the input's definitions. Each type in its `types` list (an enum label change, a non-renaming `ALTER DOMAIN`, or a drop; renames, schema moves, other type changes and `CASCADE` set `unknown`) becomes uncertain before the input's definitions apply: a qualified name marks its own key, recorded or not, which stops any search through its schema (an unqualified definition was created in a path schema, so it stays known); an unqualified name marks the existing unqualified and configured-path keys. Index and comment statements keep facts. Created definitions add facts; one for a relation that exists or may exist (an incomplete owner) makes only that relation unknown, since the snapshot does not distinguish `IF NOT EXISTS`. Schema-level issues make the input's tables incomplete and its types uncertain. `ensureExtensionExists()` is classified like raw `CREATE EXTENSION`. Explicit casts and accessors stay authoritative. + - MySQL/MariaDB identities stay exact, but each mutation also makes known relations whose database and table names differ only in letter case unknown, because some servers fold them. Lookups are not case-folded. + - Inference uses the configured connection identity and does not track session state carried across separate raw calls (`USE`, `SET search_path`). This is an analysis limitation: the Static Analysis docs state it and recommend a configured connection for another database or schema, and a short WHY comment at the impact handling records it. + - Regressions: drop-and-recreate versus `CREATE TABLE IF NOT EXISTS` after a Blueprint table, a raw drop or rename leaving no missing-column claim, a raw index preserving earlier facts, a MySQL raw `ALTER TABLE Users` after a Blueprint `users`, `removeColumn()` after `->change()` keeping the column, `whenTableHasColumn()` with a decided and an unknown guard, and `DB::transaction(fn () => Schema::create(...))` leaving no missing-table claim. +- **Diagnostics.** + - A `PropertyFetch` collector (nullsafe fetches included) records the unusable-dump facts (path, line, message) of the model's connection when a model's undeclared property is read. It does not track whether the lookup reached the schema (an `@property` tag, for example), since that would need order-dependent state in the helper. The data is serializable and loaded lazily. + - A `CollectedDataNode` rule (`hypervel.schemaDump`) reports each unusable dump (unreadable or a `ParseException`) once, at the dump file: `ParseException::$sqlLine`, else line 1. Issues and incomplete constructs are not diagnosed. + - The installed `AnalyserResultFinalizer` skips `CollectedDataNode` rules when nothing was collected, so a standalone rule cannot serve as the hook. +- **Fetched types.** + - One mapping, keyed by connection driver and column family and shared by the migration and dump paths, gives a read type (fetched) and a write type (accepted). Nullability applies next, then the fetch-option transformations. Explicit casts, the implicit incrementing-key cast (`HasAttributes::getCasts()`), dates and accessors take precedence. + - Both paths produce schema-parser `ColumnType` facts, resolved at lookup so option changes need no reaggregation. A Blueprint type maps directly to the type its driver's grammar emits, limited to facts inference needs, with PostgreSQL's canonical `pg_catalog` identity. The grammar is not run: MySQL's `typeDate()`/`typeYear()` read the live server version. Blueprint CHECK-backed enum values (pgsql, SQLite) are analyzer column metadata, not `ColumnType::$values`. `rawColumn()` reads `mixed`. + - Dates read `CarbonInterface` (`asDateTime()`), immutable casts `Carbon\CarbonImmutable`; date writes accept `DateTimeInterface|string|int|float` (`fromDateTime()`). The soft-delete column is a date only when it has no cast, an undetermined one included, as `initializeSoftDeletes()` adds its `datetime` cast only then. + - `ModelHelper` runs the trait initializers (`initializeHasTimestamps()`, `initializeHasUniqueStringIds()`) before `initializeModelAttributes()` on the memoized constructor-less instance, and nothing mutates it later, so key type and incrementing never depend on call order (upstream set `usesUniqueIds` inside `ModelCastHelper`). Cover property-before-casts access. + - Upstream differences, each commented at its mapping: + - Uncast decimals read `numeric-string` on MySQL/MariaDB, `string` on pgsql (numeric holds NaN/Infinity) and `float|int` on SQLite. They are written as `float|int|numeric-string` (`float|int|string` on pgsql). + - Booleans read `int` (`tinyint(1)`) except `bool` on pgsql. + - pgsql `bytea` reads `resource`. Binary columns on every driver (`bytea`, MySQL/MariaDB binary and blob types, SQLite blob affinity) also accept a stream, which `PdoConnection::bindValues()` binds as a large object. + - MySQL/MariaDB `BIGINT UNSIGNED` and `BIT(64)` read `int<0, max>|numeric-string`, because values above `PHP_INT_MAX` fetch as strings; shorter `BIT` columns read `int<0, max>` (native and emulated prepares fetch them as integers). There is no foreign-key or auto-increment shortcut; the implicit key cast types `$model->id`. + - MySQL/MariaDB `SET` reads `string`: it holds comma-joined combinations, not one label (upstream used the labels' union). + - SQLite numeric affinity reads `float|int` only for `numeric`/`decimal`; other names with numeric affinity (`uuid`, `string`, `geometry`) and untyped columns read `mixed`, because SQLite keeps non-numeric values as written. + - SQLite `json`/`jsonb` columns read `float|int|string`: numeric affinity stores a JSON number as a number, which PDO fetches as one. Blueprint `json()`/`jsonb()` create them only under the connection's `use_native_json`/`use_native_jsonb`, else `text`; the two flags join the endpoint identity and the aggregation fingerprint. + - Booted connection `options`, merged as in `Connector::getOptions()`, adjust the read types: + - `ATTR_STRINGIFY_FETCHES`: int families, MySQL/MariaDB decimal and double, and integer-backed booleans become `numeric-string`. pgsql numeric and float and SQLite real become `string` (`'NaN'`, `'NAN'`, `'INF'`). pgsql bool becomes `'0'|'1'`. + - `ATTR_ORACLE_NULLS`: `NULL_TO_STRING` replaces null with `''` (`TypeCombinator::remove()`, which also subtracts null from `mixed`); `NULL_EMPTY_STRING` adds null where `''` can be read. + - The column's nullability applies to every converted type: a known NOT NULL column subtracts null (`TypeCombinator::remove()`), so an unknown type, an unmapped name or an unresolved PostgreSQL type starts as `mixed~null`, else `mixed`, and passes through the same options. Write types keep `mixed`, since PHPStan's `MixedType::accepts()` ignores a subtracted null. Read nullability is whatever the resulting type allows (`! isNull()->no()`), never a separate flag; write nullability stays the column's. The exception is a cast `castAttribute()` reads through `fromJson()` (`array`, `json`, `json:unicode`, `object`; `ModelCastHelper::readsEmptyStringAsNull()`), which also reads `''` as null: a nullable fetched column reads null through it whatever the fetch options, because `NULL_TO_STRING` fetches its null as `''`. + - `ATTR_CASE`: folds fetched attribute names at the property lookup, not the schema names, cast lookups or write targets. + - Drivers without a mapping read `mixed`. + - SQLite types describe the framework's standard writes, not engine enforcement; STRICT tables do enforce their types. Explicit casts give stable application types. +- **Casts.** `ModelCastHelper` merges the runtime `$casts` property (with the implicit key cast) with `casts()`, read statically. It never runs `casts()`: one reading constructor-initialized state fails on the constructor-less instance. + - The framework's default `casts()` is skipped. With `parseModelCastsMethod`, an override is parsed with `@cachedRichParser` (the cleaning parsers strip method bodies), found by its native file and start line (traits, several classes per file). Only a single unconditional top-level array return is read; returns inside nested function-likes are ignored. It is evaluated in the declaring class's scope, so an inherited `self::` resolves and a non-final `static::` stays unknown. + - Cast names normalize as `getCastType()` reads them: a `date:`/`datetime:`/`immutable_date:`/`immutable_datetime:`/`decimal:` cast with arguments and a known class keep their spelling; anything else is trimmed and lowercased whole, so `ENCRYPTED:ARRAY` reads as `encrypted:array`. Runtime fix: `isClassCastable()` also tests `getCastType($key)` against the primitive list, so a primitive cast in another case (`BOOLEAN`) no longer throws `InvalidCastException` on write; regression in `DatabaseEloquentModelTest`. + - Values: constant strings verbatim (PHPStan already unescapes PHPDoc strings), the `[$cast, ...$arguments]` list normalized as `ensureCastsAreStringValues()` does, anything else undetermined. The constructor-less instance skips `initializeHasAttributes()`, so its `$casts` property values get the same normalization. The source types declare that form: `$casts` and `casts()` accept `[$cast, ...$arguments]` lists (arguments may be ints), while `getCasts()` stays normalized `array` (`types/Database/Eloquent/Casts/CastDeclarations.php`). + - `AsCollection`/`AsEncryptedCollection` read the collection class `using()` selects, with its templates at their bounds, when no mapper is given; with a mapper, the base `Collection`. An undetermined parsed value falls back to the same required key of the declared return shape; an unusable parse uses the declared shape. + - One shape not explicitly open (`...`) is complete: its required entries override the property casts and its optional entries are undetermined. `isUnsealed()` is `maybe` for ordinary shapes outside bleeding edge, so completeness tests `! yes()`. An open shape, several alternatives or a non-constant type leaves every attribute it does not require undetermined. + - `ModelCastHelper::getCastClass()` reads a class cast as `isClassCastable()` does: the type before `:`, never one of the runtime's `$primitiveCastTypes` (class names are case-insensitive, so `datetime` would otherwise name `DateTime`). The computed-property check and serialization use it. + - `hasCastForProperty()` includes undetermined casts; `getCastForProperty()` returns null for them. An undetermined cast reads and writes `mixed` (a date column keeps the date write type, since `fromDateTime()` runs first) and serializes as `mixed` even beside an accessor, because class casts serialize first. A key without a known column still applies its casts. + - Cast types follow the runtime. A class caster's `get()` and `set()` also run for null, so a nullable column adds null to the read type except for a `CastsAttributes` caster (its declared `get()` return decides) and to the write type except for a class caster (its `set()` value parameter decides). A `Castable` uses the single caster class its `castUsing()` declares for the cast's arguments; an ambiguous one reads and writes `mixed`. `AsArrayObject`/`AsEncryptedArrayObject` read a benevolent `ArrayObject|null`, since their `get()` returns null for a missing or non-array value. Enum writes accept what `setEnumCastableAttribute()` accepts: the enum, plus `string` (case name) for a pure enum, `int|numeric-string` for an int-backed one and `string|int` for a string-backed one. An attribute whose column or nullability is unknown may hold null; a known NOT NULL column with an unknown or unmapped type reads `mixed~null` unless a fetch option can produce null. A class cast serializes with null added when the value may be null, since `addCastAttributesToArray()` serializes only a set value. + - `CastDefinitionDynamicStaticMethodReturnTypeExtension` types an explicit map of framework helpers (`AsArrayObject::nullable`, `AsBinary::of`/`ulid`/`uuid`, `AsCollection::nullable`/`of`/`using`, `AsEncryptedCollection::of`/`using`, `AsEnumArrayObject::of`, `AsEnumCollection::of`) as the string the real helper returns. It applies only when the receiver is exactly the declaring class (a subclass could redirect `static::` calls) and every argument is a single constant its parameter type accepts; arrays only as callable mappings that `is_callable()` accepts. Otherwise the declared `string` applies. + - PHPStan reanalyzes dependents only when declarations change. The model's metadata tracking (Result-cache correctness) covers the method's file; parsing also tracks, on the model, the files the constants and static methods its return uses come from (`SourceFiles`). The helper extension tracks its own source on the caller scope. + - Regressions: `Type/data/model-casts-parsing.php` and the warm body-edit case in `ModelPropertyDependencyTest`. +- **Assignment.** `ModelProperty::canChangeTypeAfterAssignment()` is true only for raw schema attributes: no cast (including the implicit key cast and undetermined casts), not a date, and no accessor or mutator. PHPStan then narrows after assignment, while transforming attributes keep stable read types. +- **Accessors and mutators.** Each direction follows Eloquent's precedence, falling back to the database property (with its casts and dates) when it has no handler: + - Reads: `get{Name}Attribute()`, then an `Attribute` get callback (`TGet` other than `never`). Writes: `set{Name}Attribute()`'s first parameter (`mixed` without one), then `TSet`. + - A virtual attribute's fallback is raw `mixed` with its casts and dates; an uninstantiable model's is `mixed`. + - Recognition matches `HasAttributes::hasAttributeMutator()`: a non-private method whose native return type names `Attribute` exactly. The generics come from PHPDoc; a bare `Attribute` is ``, and PHPStan's `missingType.generics` reports the missing annotation. Public methods count; the visibility rule reports them separately. + - Serialization prefers an `Attribute` getter to `get{Name}Attribute()` (after class casts), as `mutateAttributeForArray()` does, and converts dates only on the `Attribute` branch. A set-only `Attribute` serializes through the column's casts. Every appended attribute takes the `mutateAttributeForArray()` path too: a non-enum class cast (inbound or `Castable` included) reads as the attribute's readable type, so `get()`'s return or an inbound caster's raw value, without `serialize()`; else the getters; an appended attribute with neither is `mixed`, since `mutateAttribute()` then calls a missing method. An `Enumerable` converts element-wise only when its `toArray()` is `Collection`'s or `LazyCollection`'s; an override keeps its declared return. + - Runtime fix in `HasAttributes::mutateAttributeForArray()` (Laravel shares both defects): enum casts skip the class-cast branch (`! isEnumCastable() && isClassCastable()`, as `isClassSerializable()` does), since instantiating the enum threw for an enum-cast column with a getter or in `$appends`; and the `Attribute` branch asks `hasAttributeGetMutator($key)`, since `cacheMutatedAttributes()` replaces the read cache with snake-case keys, making a camelCase appended accessor depend on whether a property read came first. Regressions for both. + - Unlike upstream, a direction without a callback is not `never`, legacy setters type writes, and bare or public `Attribute` methods are not hidden. + - Regressions: get-only and set-only accessors over a column, a virtual fallback with a cast, legacy setter input, legacy getter precedence beside an unrelated camelCase method, bare and public recognition, set-only serialization through a cast, an inherited legacy getter beside a child `Attribute` getter, and the `Attribute`-only date conversion. + +Track connection/table identity where it changes the result, schema changes across migrations, nullability, defaults, enums/casts and supported custom driver boundaries. If a dynamic migration or unsupported custom-driver construct prevents a complete schema, preserve known declarations and avoid reporting an unproven missing column. Do not turn all unknown properties into `mixed` globally or suppress unrelated mistakes. Preserve upstream extension points instead of inventing a driver plugin platform. + +## Result-cache correctness and analysis cost + +- Retain PHPStan's `DependencyTracker`/`DeclarationDependencyTracker` use for configuration, migrations, schema dumps, views and translations. Track both changed files and directory membership so newly added/deleted files invalidate results. Keep declaration-based dependencies for reflection extensions and scope-based dependencies for return types/rules. +- Config sources come from one place, `ConfigSourceResolver::getKeySources()`: in each config directory the file of every key prefix (existing or not) and the key's own directory (`*.php`, recursive, so a nested file created later is noticed), then the framework file and overlapping package merge files. `ConfigParser` uses it with its config directories and carries directories through its cached dependency records, so a value read through another config file replays them. The schema facts read the booted repository, so `ModelPropertyHelper` declares the sources of every key `ConnectionConfigResolver` read (its own and the migrations' connection keys, also on a `MigrationCache` hit) against the booted directories, once the facts are loaded, on `Model` and on classes using `model-property`. Regression: a warm run after a config edit that moves a migration to another connection. Provenance of runtime-copied files, provider writes and inputs, `.env` and environment identity stay with the shared tracking below; the final warm-cache verification covers all of them, including schema connection config. A directory a glob `configDirectories`, `databaseMigrationsPath` or `squashedMigrationsPath` entry starts or stops matching has no tracked path, so `GlobalInputsResultCacheMetaExtension` hashes the three resolved lists (`Support\DirectoryResolver::resolve()`, which the config and schema readers share and which needs no boot; configured order kept, a missing literal directory kept so it is watched before it exists) and a change invalidates the whole cache. Regressions: warm runs that add and then remove a config directory, and that add a migration and a schema-dump directory. +- Schema sources besides migrations and dumps: constants naming tables, columns or connections, `foreignIdFor()` model metadata and a migration's parents and traits. `SchemaAggregator` records their files: column-level ones (column names, allowed values, `foreignIdFor()` models) on the table, so only models reading that table depend on them; identity-level ones (table names, a rename's target, migration parents and traits) on every model through `Model`. A guard (`hasTable()`, `hasColumn()` and the like) reads one table's facts to decide what happens to any table, so that table's sources become sources of every fact. `MigrationCache` stores their hashes, validates them on a hit and replays them. Regressions: warm edits of a constant a migration reads and of one a guard reads. +- Model metadata. PHPStan's exported declarations omit property defaults and method bodies, and a changed constant reanalyzes only the files naming its class. `ModelHelper::getInstance()` therefore tracks, once per model on the model class, the files declaring it and its parents below `Model` with their traits, and the files of constants their property defaults read. A constant read through a class depends on that class and its parents up to the declaring one, which can override it, and on the constants its value reads (`SourceFiles`). An overriding `getConnectionName()` can read any config value and is not interpreted, so that model also depends on every booted config source (`ConfigSourceResolver::getAllSources()`: the config directories, the framework config directory and package merge files), computed once. Regressions: warm edits of a parent's `$connection` default and of the config such an override reads. +- Forwarded builder methods. A caller depends only on the class it calls through and the classes its types name, so a call through a model or relation never depends on the model's custom builder or query class. `BuilderDependencies` declares, per builder instantiation (`getCacheKey()`, since the query class can come from active template types), class dependencies on a custom builder and query class and the file declaring a custom `$passthru` (a property default, so not a declaration). It records them on the owner every caller depends on: the model for model calls, the related model for relation calls (never the shared relation class), the builder for direct calls. Both forwarding extensions register before resolving the method, so restoring a removed method clears the stale error. Macro registrations stay with the shared tracking below. Regressions: warm builder (passthru alone, then a declaration) and query-class edits with recovery, each route calling through its own classes so one route's inputs cannot mask another's. +- **Shared runtime inputs.** Every boot-derived fact (container keys, facades, macros and mixins, managers, auth guards, console definitions, storage disks, the morph map, queue routes and forwards, worker-state bindings/routes/middleware/listeners/Bus maps, the booted config the schema and queue rules read, translation and view registrations) declares one aggregated value, `RuntimeInputsResultCacheValueExtension`, through `Support\RuntimeInputs` on the scope or the declaration tracker, before every early return (misses, unresolved drivers and collector decisions depend on boot too). A class-level declaration reaches every file depending on the class or a descendant, so it is made only where the boot decides the fact: a macro lookup declares it once per class, and only for a Macroable class, a builder or a facade (any other class has no macros whatever the boot registers). Native per-file tracking would record about 75 values per call site. The main process restores the cache before it boots: workers fork from it after the restore and boot themselves, and the main bootstrap runs only after they finish. So the value is computed from its key alone and memoized per process. Workers inherit the classes the restore loaded, so a warm worker's key can list more boot-loaded files than a cold run's; that superset is kept (the extra entries are unchanged files), and the runtime-input tests prove cold → warm → warm runs stable. The key holds: + - **Files**, by content or `missing`: everything `get_included_files()` holds after boot, except PHPStan's own files (`phar://` paths, the analyzer, and PHPStan's `tmpDir`: its compiled container embeds the run's environment, including the restart's per-run `PHPRC`, whenever a config file's mtime changes) and files under the realpath of Composer's vendor directory, unless their `installed.json` entry is a mirrored path repository (`dist.type: path`; symlinked path packages already resolve outside vendor). Plus Composer's `installed.json`/`installed.php`, the root `composer.json`, `bootstrap/providers.php`, the Testbench YAML candidates in the directory the loaded configuration names (`getConfigurationPath()`) and the application-mode environment files below. These listed files keep their own paths, never resolved, so a symlink pointed elsewhere is noticed; a generated cache output is still recognized by either spelling, since an included one arrives resolved. A class file is never excluded, because a registration helper's body can change bindings. Consequence: in the components repo and path-repository consumers, editing any framework file the boot loads reanalyzes every boot-fact consumer. + - **Config directories** the boot loads (`ConfigSourceResolver::getBootedConfigDirectories()`), by the names and contents of their config files, so an added file is noticed (not through symlinked directories, which the loader's Finder does not follow). Not while configuration is cached: the boot then reads only the cache file, which the files entry tracks. + - **Environment names and patterns** (below). + - **Presence-only paths:** the Testbench SQLite file (below). + - Paths under PHPStan's working directory are written relative to it (the value extension's `createKey()`), others stay absolute; `getValue()` resolves them, so `keyToResultCache()`/`keyFromResultCache()` are identity. This keeps the key small: every `ValueDependencyCollector::record()` call copies the whole key into its id (`getId()` concatenates the extension class and key) and PHP hashes that id as an array key. +- Per-fact sources besides the shared value: matched console commands' `SourceFiles` (a `$signature` default is not a declaration); config directory membership through native directory dependencies on `ConfigSourceResolver::getAllSources()`. PHPStan records a called method's declaring class, so a found macro or facade method needs nothing more (a closure's file ran during boot). A class the boot selected whose declaration decides a fact native recording misses gets a class dependency through `RuntimeInputs::trackClass()`: a facade root or default disk that lacks the method, a manager's driver behind a `ProxiedMethodReflection` (declared on the manager), and a guard whose methods `AuthManager` forwards. Where a method body selects the fact, `RuntimeInputs::trackSources()` adds the selecting class's `SourceFiles` below its framework base (memoized per class and base, declared on every call) on top of its class dependency, which still covers inherited declarations and the base class: the booted manager or bound subclass whose `getDefaultDriver()` picks the driver, the facade whose accessor picks the root, and the filesystem manager. Regressions: a warm body-only edit of a manager's `getDefaultDriver()`, and an edit of the interface whose PHPDoc a bound manager subclass's driver creator inherits (only the class dependency covers it). Resolving a binding can load its class after boot, and a declared type is read without loading it, so the shared value does not cover them. +- Scopeless consumers declare on a class: class reflection extensions on the class they describe; the morph map on the class whose PHPDoc uses a morph map type (`EagerLoadMapTypeNodeResolverExtension`, through `NameScope::getClassName()`: the Model and MorphTo stubs and Eloquent `Collection`'s own `loadMorph()`/`loadMorphCount()` docblocks); schema facts on `Model`. +- **View names.** `ViewStringType` asks the booted finder whether a view exists, which depends on which view files exist, never on their contents. A separate views key, so adding a view reanalyzes only view consumers: one `listing` entry per finder directory (`getPaths()` plus every `FileViewFinder::getHints()` path; a finder that is not a `FileViewFinder` contributes its paths), each digesting the sorted relative names of every file under it (pattern `*`: one walk, and a non-view file there only reanalyzes view consumers), served by `RuntimeInputsResultCacheValueExtension`. The walk follows symlinked directories, as the finder does, except a link to a directory it is already inside. Runtime-copy provenance compares the names only. `RuntimeInputs::trackViews()` declares the views key and the shared key (the finder's paths, hints and extensions come from the boot). No recording inside `ViewStringType`, which has no scope. Recording sites: + - Class members: `ViewStringTypeNodeResolverExtension` declares on `NameScope::getClassName()` for `view-string` and for an identifier naming a type alias (global, or local and imported through the `NameScope`) whose resolution contains a `ViewStringType`: the extension resolves such an identifier through the `@api` `TypeNodeResolver` (leaving the nested call to core with a per-name guard) and returns the result (`TypeAliasResolver` is not `@api`). PHPStan resolves an alias in the scope of the class or configuration defining it, so this is the only point that sees the class using it. Covers the stubs (including the `@phpstan-assert-if-true view-string` on the View factory's `exists()`) and user classes and traits. + - Free functions: `ViewStringFunctionDependencyRule` (`FuncCall`) tracks when the named function's parameter, return or assertion types contain a `ViewStringType` (`ViewStringType::occursIn()` traverses callable types; memoized per function, tracked on every matching call). Covers `view()` and user helpers. + - The declaring file's own checks (a literal returned under `@return view-string`, a property default, an inline `@var`, a callable invoked through an aliased parameter): `ViewStringFileDependencyRule` (`FileNode`) tracks when the file's text contains `view-string` or the name of a global alias whose definition resolves to one (`TypeStringResolver`, computed once). A class file's own member checks also depend on it through the class declaration when PHPStan notes the file's own class. +- **View and translation files.** The unused-view and missing-translation rules read view and translation files through symlinked directories, as the finder and translation loader do (`Support\RecursiveDirectoryIterator::followingLinks()`, the walk the listings share). PHPStan's directory dependency does not enter linked directories, so the rules declare their view and translation directories through `RuntimeInputs::trackLinkedDirectories()`: one value per call over names and contents, following links, separate from the shared key so a template edit reruns only these rules. These directories and the view listings are recorded by their own paths with trailing separators trimmed, never resolved, so a symlinked root pointed elsewhere is noticed. Regressions: a warm run with collected-data error reuse after editing a linked template without renaming it, and keys recorded through `RuntimeInputs` evaluated again after a root link is retargeted with its old target unchanged. +- **Environment.** `bootstrap.php` captures `$_SERVER`, `$_ENV` and `getenv()` before boot; restoration reads the live maps. A name's digest is the triple each default adapter reads (ServerConst/EnvConst: a scalar, booleans as `'true'`/`'false'`; Putenv: a string), with missing distinct from `''`. Only digests are persisted. Names come from the retained boot sources, every included script, vendor ones too (framework config files, helpers such as `app_id()`), and, from versioned vendor packages, the boot-loaded class files of each package that supplies a loaded provider (a provider can delegate registration to a helper in its own package) plus every loaded provider's parent and trait files. Other vendor classes are not scanned: the dotenv adapters `Env` always loads, Symfony's `Request` and `CliDumper` would force the whole-environment fallback, and the rest read run-time terminal and IDE detection (`COLUMNS`, `TERM`, `NO_COLOR`) that would reanalyze every boot consumer when switching between editor and CLI. Content hashing and name discovery are separate selections. Recognized reads are constant names read by `env`, `env_array`, `env_or_fail`, static `Env::get`/`getArray`/`getOrFail`/`has`, `getenv()`, constant `$_ENV`/`$_SERVER` offsets and `Env::getRepository()->get()` (a first-class callable of any of these, such as `env(...)`, is a dynamic name); plus the names the selected env file interpolates (dotenv's `Parser`, `Value::getVars()` read with `Resolver`'s `${NAME}` pattern; no evaluation). `self::`, `static::` and `parent::` calls inside an `Env` subclass are `Env` reads. Whether a class is an `Env` subclass or a modeled reader is loaded is decided against the classes declared when `captureBoot()` ran, never the live state: extensions autoload classes during analysis, which would give the main process and the workers different keys. Dynamic names inside those reader functions and `Env` are plumbing. Modeled dynamic sites map to patterns: `HandlesDatabaseConnections::usesDatabaseConnectionsEnvironmentVariables` to `MYSQL_*`, `MARIADB_*`, `POSTGRES_*`; `EnsuresDefaultConfiguration::bootstrap` to `APP_KEY`, `APP_DEBUG`, `DB_CONNECTION`; `Application::normalizeCachePath` to `APP_*_CACHE`; `ParallelTesting::option` to `HYPERVEL_PARALLEL_TESTING_*`; Testbench's `defined_environment_variables()` to none (it forwards the environment to subprocesses). Patterns stay in the key and `getValue()` matches them against the incoming maps, digesting matched names and values, so a newly set variable is noticed. Patterns skip `PHPRC`: PHPStan's restart points it at a new temporary ini file on every run, which would make the whole-environment fallback never reusable; a named read still tracks it. Any other dynamic name selects the whole environment. The scan runs once per process, on first use, with PHPStan's non-cleaning simple parser (the cleaning one drops function bodies; the rich one doubles the cost), and skips only files matching neither `$_SERVER`, a word ending in `env`, `env_array` or `env_or_fail`, nor the short name of an `Env` subclass loaded at boot (case-insensitive), which every recognized read contains. The app, package and components repo boots assert that they do not fall back to the whole environment. Hashing the whole environment instead would lose reuse across sessions and CI runs (session identifiers, SSH ports, CI run ids). + - Application mode tracks the base `environmentPath()`/`environmentFile()` captured once `bootstrap/app.php` returns (so `loadEnvironmentFrom()` is honored), its `.{APP_ENV}` candidate for the incoming `APP_ENV`, and the file selected after boot. While configuration is cached, `LoadEnvironmentVariables` skips the file, so neither these files nor their interpolated names are tracked or parsed (a malformed unused file must not fail analysis); the tracked config cache decides, and removing it restores them. Package mode loads no environment file: `Testbench::create()` leaves `load_environment_variables` false, applies the YAML `env`, and marks the kernel bootstrap complete. +- **Package provenance.** Package-mode boot paths point into Testbench's per-process runtime copy, which disappears between runs. `Bootstrapper` records the skeleton source of the copy it owns (`runtimeSourcePath()`). `RuntimeInputs` maps a path in that copy to the skeleton path when both have identical content or are both missing (a directory when names and contents match, through its symlinked directories for view and translation directories; a view listing when names match), memoized per path. The shared key and the narrower trackers (`ConfigParser`, `ModelPropertyHelper`, `ViewFileHelper`, `NoMissingTranslationsRule`) use it; parsers still read the runtime paths. The application's cache files (`getCached*Path()`) with no identical skeleton counterpart and Testbench's process marker are boot outputs and are excluded. Any other unmapped runtime path (an added or overwritten fixture file, a borrowed runtime, a predefined `BASE_PATH`) keeps its runtime path and invalidates conservatively. No blanket prefix substitution. +- Boot-derived config mutation targets depend on external conditions: Testbench rewrites `database.default` only when the SQLite database file is missing. When the bound `Foundation\Bootstrap\LoadConfiguration` is Testbench's loader (the application itself is a plain foundation application), the shared key holds that file's presence, whatever `database.default` now says, mapped to the skeleton by relative path (the rewrite runs right after the copy), never its content. No new tracking in the config parser, no boot during cache restoration. +- PHPStan 2.3.5 hashes the files of known extensions but not the list of attribute-registered services, so adding an `#[AutowiredService]` extension left warm results stale (the components repo and path-repository consumers; versioned installs are covered by `changedPackagesProvideContainerClass()`). The owner-approved `AttributeServicesResultCacheMetaExtension` hashes the sorted `%attributeServicesClasses%` list, with a removal `@TODO` and `AttributeServicesResultCacheMetaExtensionTest` (handoff `_tmp/pr-ideas/phpstan-new-attribute-services-result-cache.md`). Keep it separate from the shared runtime-input tracking so it can be removed on its own. +- **Pre-boot meta.** PHPStan computes meta once, in the main process before any boot. One meta extension hashes the global inputs: `HypervelStubFilesExtension`'s stub-directory membership (PHPStan rechecks recorded stub hashes but not a changed list) and the boot-identity selectors, which choose which application, loaders and registrations exist: `APP_ENV`, `APP_BASE_PATH`, `COMPOSER_VENDOR_DIR`, `APP_*_CACHE`, `APP_RUNNING_IN_CONSOLE` and `TESTBENCH_*`, digested like the environment value with patterns matched freshly. 2.3.5 deprecates the interface in favor of value tracking, but it remains the only mechanism that invalidates every file, so it carries a reasoned `class.implementsDeprecatedInterface` ignore. It stays separate from the attribute-services workaround. Do not serialize the live container or boot during cache restoration. +- Warm-versus-cold subprocess fixtures, reusing one app and one package setup: + - App: a provider macro edit changes a valid call into an error and back, with a `mixin()` class referenced only by the provider; a body-only edit of a registration helper the provider calls; a route-file and a provider binding edit for the worker-state rule; adding a stub file; an `APP_ENV` change; an undeclared process-only config variable set and unset; a `${NAME}` interpolation in `.env` whose process value changes; main/worker digest consistency in normal parallel analysis; no whole-environment fallback; a facade root gaining a method the facade lacked and a manager driver's method changing (class dependencies); a morph-map edit seen by a collection `loadMorph()` call; views: a free-function `view-string` parameter with the view added, deleted and restored, a class-member caller, a namespaced (hint) view, a global type alias `Closure(view-string): void` invoked through a parameter, and a template-body edit with unchanged names that reanalyzes none of them. + - Package (Testbench): an unchanged second run reanalyzes nothing although Testbench creates a new runtime directory; a skeleton config edit; a YAML `env` edit; a `MYSQL_DATABASE` override unset, set, unset; SQLite presence in both directions; no whole-environment fallback. + - The components repo: no whole-environment fallback. + - Show reuse through PHPStan's ordinary result-cache output; identical warm and cold diagnostics alone cannot show it. No timing threshold, benchmark, `--debug` or routine cache clearing. +- Preserve the migration cache's locking, second read under lock and atomic replacement. Port upstream `MigrationCacheTest` and dependency tests, then add the changed-fingerprint regression: `getMTime()` has whole-second resolution, so ordinary edit-and-analyze loops can reuse a stale schema after two edits in one second. Keep mtime fixed explicitly in this regression instead of relying on test timing or sleeps. Use existing content fingerprints or a once-per-input `xxh128` digest and add a short WHY comment at the change so an upstream sync preserves it. Preserve upstream's timestamp-change invalidation alongside content changes so existing applicable tests remain meaningful. The fingerprint also covers the normalized config the aggregation reads (statement-connection facts, routing, `database.default`, `Builder::$defaultMorphKeyType`) and the installed `hypervel/schema-parser` version and reference. Connection-name keys that migrations read are known only after aggregating, so the entry stores each one's presence and value and is used, on both reads, only while they match. No new concurrency suite is needed for unchanged locking. Keep one parsed-schema cache, bounded in-process reflection caches and normal PHPStan temp storage; no extra persistent metadata service. +- Keep runtime source changes free of analysis-only request overhead. Parse lazily, reuse reflection/ASTs, and do not resolve every container service or model for every node. Use ordinary bounded correctness runs during development; get fresh owner confirmation that the machine is idle before any benchmark. Compare representative runtime paths only when needed to assess a concrete cost. +- Final efficiency pass, after the shared tracking. Agents re-run the analyzer constantly while building and maintaining the components repo, apps and third-party packages, so re-runs must be fast. Owner requirement: + - Treat this as an optimization round, not just verification. Find and implement worthwhile, straightforward improvements to startup cost, repeated parsing/reflection, duplicate work and cache reuse. Preserve correct diagnostics and dependency invalidation; prefer targeted reanalysis and reuse of unchanged results. Require evidence of benefit proportionate to added complexity, and reject brittle workarounds or elaborate machinery for negligible gains. + - An unchanged warm run reanalyzes nothing for an application, the components repo and a Testbench-backed package (this needs the runtime-copy provenance fix above), with diagnostics equal to the cold run's. Owner-accepted exception: the first warm run after a run on an empty tmpDir may reanalyze the dependents of class dependencies whose ancestors include a built-in class, because PHPStan 2.3.5's `ClassResultCacheValueExtension` hashes the stub file name, which `CachedPhpInternalSourceLocator` returns unnormalized on a cache miss and canonical on a hit. No local workaround; every later unchanged run reanalyzes nothing. `RuntimeInputsDependencyTest` asserts the first-warm count last, so a failure there is attributable. Handoff: monorepo `_tmp/pr-ideas/phpstan-internal-stub-path-cache-instability.md`. + - Check every declared dependency for unnecessary invalidation and confirm normal warm reuse. The known conservative boundaries are accepted without evidence against them: whole-file model metadata tracking (a body-only edit to a shared base model reanalyzes every model user), the recursive default migration-directory pattern (a nested file migrate never reads invalidates) and all-config tracking for an overriding `getConnectionName()`. Also accepted, as the residual invalidation of the shared-input design: in the components repo, editing a boot-loaded framework file reanalyzes the files that read boot facts (about a third of the repo, ~39 s against ~4 s for a leaf edit). Comment-stripped digests would help only comment edits, and a post-boot fact fingerprint would have to enumerate every fact consumers read, leaving results stale wherever it missed one. + - No repeated work per run, and bounded caches. Each process scans the boot's environment reads once (~120 ms); a persistent scan cache is not used, because a correct key would also need the class constants the scan reads and the scanner's version. + - Measure application cold and warm timings with consumer migration, with fresh owner confirmation that the machine is idle. The components repo is measured. + +## Upstream rule disposition + +Copy retained rule files first, then adapt them. Retain their relevant toggles and tests. The table covers all 28 upstream rule classes; helpers such as `ModelRuleHelper` and `RelationExistenceHelper` follow their consumers. + +| Rule | Final behavior | +|---|---| +| `ArrKeyExistenceRule` | Diagnose definitely absent `Arr::get`/`pull` keys; respect optional keys, defaults, dynamic shapes and actual argument binding. | +| `CheckDispatchArgumentTypesCompatibleWithClassConstructorRule` | Preserve job/event constructor checking, conditional dispatch, named arguments, unpacking and callable dispatch variants actually supported by Hypervel. Unlike upstream, a by-reference constructor parameter takes any expression: the dispatch methods pass their variadic arguments' values to the constructor (`ProxiedMethodReflection::proxyVariant()`). | +| `ConfigCollectionRule` | Port against the adapted config parser for every receiver (Config facade, repository contract, `config()`); report only known values that are definitely not arrays, never unknown keys. A new `ConfigTypeRule` (`hypervel.configType`, under `checkConfigTypes`) reports typed getters whose known value can never match; both share lookups through `ConfigHelper`. Both messages name the getter that throws and say to correct the config value or use the getter matching its type; the docs reserve `get()` for intentionally nullable, union or mixed values. Do not forbid legitimate nullable/union `get()` calls. | +| `ConsoleCommand/UndefinedArgumentOrOptionRule` | Use booted command definitions, including signatures and inherited/configured definitions. | +| `ModelAppendsRule` | Support `#[Appends]` and currently supported property declarations. A database-backed attribute can be appended with a getter/class cast. Distinguish readable accessors from setter-only `Attribute` definitions; preserve custom serialization. | +| `ModelRelationDefaultsRule` | Validate `$with`/`$withCount` against real relations, nested paths, aliases and inherited definitions. | +| `NoEnvCallsOutsideOfConfigRule` | Retain the policy, closing upstream's bypass: match every normal reader, not only `env()`. These are the global `env`, `env_array` and `env_or_fail` functions, `app_id()` (a config-default helper that reads `APP_ID`/`APP_NAME`, so a runtime call gets a fallback id once configuration is cached), and the static `Env::get`, `getArray` and `getOrFail` methods, by resolved identity (case-insensitive names; `is(Env::class)` covers aliases and subclasses). One identifier; the message names the call. Only named calls are matched: creating a first-class callable is not a read, and string callables or dynamic receivers are not traced. Raw `Env::getRepository()`, `getenv()` and superglobal access are deliberate low-level access the rule does not match. The docs state these limits; do not claim complete environment-access detection. Permit the application and framework config directories, `LoadConfiguration::frameworkConfigStubPath()` (the `config:publish` templates), plus package config files: merge paths (even when the key or execution is uncertain) and PHP sources `ServiceProvider::pathsToPublish()` maps into the config directory, including directory mappings. A merge path does not permit its whole directory. Do not claim outside-config calls return null: cached configuration only stops `.env` loading. Upstream's bare `str_starts_with()` lets `/app/config` permit `/app/config-sibling/x.php`; match the normalized directory itself or a path beneath it, and cover the sibling-prefix case in the existing test. Fixtures: one case per entry point, an alias, a subclass, the stub directory and the existing namespaced-function negatives. The components repo keeps the rule on with no directory or path-scoped ignores: such ignores would hide a future service setting read from the environment instead of config. Framework `src/` outside config uses `Env::get()` (the owner's spelling for intentional reads; it does not correct a placement violation); each verified, deliberate infrastructure read gets an inline `@phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (reason)` stating its semantic justification: reads before configuration loads, process transport such as `__HYPERVEL_CONTEXT`, test infrastructure such as worker tokens, and the `env*()` wrappers' delegation. In `tests/` (not analyzed), plain reads use `Env::get()` and tests of the helpers keep calling them. | +| `NoMissingTranslationsRule` | Reuse the booted translator, existing usage collectors and resource dependencies; do not rebuild lookup in a file scanner. Restrict default diagnostics to literal structured keys with a known group source and known standard file-backed resolution. See the translation predicate below. | +| `NoModelMakeRule` | Use the conservative declaration/runtime-metadata exclusions below, without interpreting arbitrary model boot code. Unknown or customized construction stays silent. | +| `NoPublicModelScopeAndAccessorRule` | Recognize what the runtime exposes: a legacy scope by `Model::isLegacyScopeMethod()`'s name test plus a first native parameter that can accept an Eloquent builder (Passport's OAuth helper `scopeExistsIn(string ...)` is not a scope), an attributed scope by `#[Scope]`, and an accessor by a native return that is exactly the named `Attribute` type (`?Attribute` included; unions and intersections are not, as `hasAttributeMutator()` requires a `ReflectionNamedType`). Skip methods a parent or interface declares. Only private methods get the protected autofix (it also makes a broken private scope or accessor work); public ones can have callers in other files (README difference). When `noLegacyModelScopes` is on, skip legacy scopes that rule reports (`ModelRuleHelper::attributedScopeName()`). | +| `NoUnnecessaryCollectionCallRule` | Report only a named, equivalent replacement over a fully visible chain; anything unknown stays silent. **Chain:** a `get()` or model static `all()` fetch with the default projection (`count`/`isEmpty`/`isNotEmpty` also accept `pluck()` without a key), rooted at an exact plain model (the shared `NoModelMakeRule` receiver and no-global-scope checks: scopes may add any clause, including `addSelect()`) or `DB::table()`, also through `DB::connection()`. Every link is a filter, ordering, eager load, join or limit from the rule's name lists, resolved in `Builder::__call()`'s order, so named scopes and macros end the walk, as do selects, grouping, unions, callbacks, relations and variables. The model's `newCollection()` must be `HasCollection`'s, and the collection's constructor, method and the collection methods it calls the framework's. **Cases:** `first()` → `first()` (limit absent or ≥ 1); `isEmpty()`/`isNotEmpty()` → `doesntExist()`/`exists()` (any limit: `compileExists()` wraps the full select); `count()` → `count()` and `take(n)` → `take(n)->get()` (n ≥ 0, no limit family); Eloquent `pluck(col)` and `modelKeys()` (no key argument or joins) only for a fetched column without a get accessor or class cast, on a model that hydrates and reads attributes as Model does with no retrieved handler, since `Builder::pluck()` returns raw values or reads them from a single-attribute model. Unordered `first()`/`take()` pick unspecified rows in both forms, as upstream accepts. **Omitted:** `last`, `pop` (no query equivalent), `skip`/`slice` (keys preserved), `where`, `whereStrict`, `firstWhere`, `contains` (PHP versus SQL comparison), the aggregates (driver numeric strings, collation, casts), `diff`, `find`/`only`/`except` (loose key comparison; no builder `only`/`except`). Keep the Only/Except settings. Probes: `limit(0)->get()->first()` is null while `limit(0)->first()` returns a row; `get()->take(-1)` returns the last row while `take(-1)->get()` returns all; `distinct()` changes `pluck()`'s projection. | +| `NoUnnecessaryEnumerableToArrayCallsRule` | Recommend `all()` only for the normal collection behavior and values requiring no recursive `Arrayable` conversion. Cover overridden `toArray()`/`all()` and custom collection semantics; element type alone is insufficient. | +| `Queue/BatchedJobIsBatchableRule` | Preserve the runtime `Batchable` requirement and inherited traits. Cover `Bus::batch()`, `QueueingDispatcher::batch()`, `PendingBatch::add()` and `Batch::add()` (which does not validate), including single jobs and iterable element types; not `bulk()`. It shares the call matcher and job-argument collection with `NoBatchedUniqueJobRule`. A chain's jobs are separate entries; a union with an array member (all-array unions included) stays one entry with its alternatives intact, as does an all-array union passed as the jobs argument or unpacked into it (`getIterableJobTypes()`). `InspectsQueuedJobs::getMatchingJobs()` walks it: a chain or list matches through any job, a union through every alternative for the batchable rule (`onEveryPath`) or any for the unique rule, and a leaf through the rule's existing all-classes (batchable) or any-class (unique) test. Each rule reports once per entry, naming the matched jobs' classes once each. Runtime fix: `PendingBatch::ensureJobIsBatchable()` continues past a chain's closure or nested batch instead of returning, which skipped later jobs. | +| `Queue/JobDispatchedInTransactionUsesAfterCommitRule` | Adapt as specified below; retain its switch. | +| `Queue/JobWithModelPropertyDeclaresSerializesModelsRule` | Include protected/private/nullable model state and supported model collections. Recognize inherited serialization and valid custom `__serialize()`/`__unserialize()` behavior. | +| `Queue/NoBatchedUniqueJobRule` | Cover the verified batch/bulk paths that bypass uniqueness acquisition, with an accurate explanation. Do not generalize to dispatch paths that acquire locks normally. | +| `Queue/UniqueJobDeclaresUniqueIdRule` | Require explicit identity for parameterized unique jobs; permit intentional class-wide identity through an explicit constant-returning method. Do not invent an identity or include secrets in diagnostics. As `UniqueLock::getKey()` reads it, a `uniqueId()` method wins and must be public; without one, only a public `$uniqueId` property counts. A non-public method or property gets its own "not public" message. | +| `RelationExistenceRule` | Preserve relation existence checking for loading/query APIs, including nested paths, selected columns, polymorphism and dynamic uncertainty. A name with a booted dynamic relation (`ModelHelper::getRelationResolver()`, reading `relationResolver()` on the model instance, inherited resolvers included) exists; read the registry only, never invoke the resolver. A native method whose return may be a relation (an untyped relation method, see Models) exists too; a method requiring arguments (even with a declared relation return, since loading calls it without any) or a definite non-relation does not, and builder methods forwarded through `__call()` are no evidence. A resolver or method with a definite relation return continues the path through its related model; one without ends it. `ModelRuleHelper` shares `ModelPropertyColumnRule`'s receiver normalization (a class-string receiver is its model) and model resolution from `Builder`'s `TModel` or `Relation`'s `TRelatedModel`, so custom builders with other type parameters and non-generic builders resolve; relation existence checks only a single concrete model. Names follow each runtime family: eager loading and aggregates parse a `:columns` selection (except `with()`'s name paired with a closure, and unlike upstream including Hypervel's `withWhereHas()` with a callback); the `has`/`whereHas`/`whereRelation` families and `withWhereRelation()` use the name as written; dotted paths are followed except by aggregates and the polymorphic queries, which get the relation directly. Each path segment is resolved on the model's type, keeping a generic model's type arguments. | +| `UselessConstructs/NoUselessValueFunctionCallsRule` | Resolve parameters correctly and preserve all argument evaluation effects; spread arguments are not ordinary positional values. | +| `UselessConstructs/NoUselessWithFunctionCallsRule` | Same argument/effect requirements; reordered named callback/value arguments are valid. | +| `DeferrableServiceProviderMissingProvidesRule` | Omit: no deferred-provider surface. | +| `NoAuthFacadeInRequestScopeRule`, `NoAuthHelperInRequestScopeRule` | Port as opt-in preferences, default off. Supported auth APIs remain valid. Preserve guard/custom request-resolver semantics; a variable named request does not prove an autofix equivalent, so do not offer that replacement automatically without such proof. | +| `NoImplicitQueryBuilderCallRule` | Port as opt-in, default off. `User::where()` remains the documented, supported form; an enabled preference may request explicit `query()` without changing framework semantics. Report (and fix) only a model forwarding as Model does: `__call()` and `forwardCallTo()`, plus `__callStatic()` and `query()` for static calls. A custom `newQuery()` or builder runs in both forms, so it is still reported. | +| `OctaneCompatibilityRule` | Omit and replace with Hypervel-specific lifetime checks; ordinary container/config injection is supported. | +| `UnusedViewsRule` | Port as opt-in, default off. Report that no static usage was discovered, with the limitation for dynamic/package-exported views; never provide a deletion fix or claim this proves the view unused. Collectors match method names case-insensitively and read a view argument's type, so constants and finite unions of known names count. Blade usages come from component tags, matched as Blade matches them (`getConnectionName()` non-null (the `$connection` default or `#[Connection]`), not a `::read` role (the writable name drops it), with Model's `getConnectionName()`, `setConnection()`, `getConnection()` and `resolveConnection()`; default-connection models stay silent. Receivers: a fully qualified name is exact; an expression must be constant class-strings, each qualifying, while an open class-string or object receiver needs a final class; `self`/`static`/`parent` forward late static binding, so they need a final calling class; `make` matches case-insensitively. Use existing reflection/runtime metadata, not a static boot interpreter or forced model instantiation solely for this rule. Include the attributed-hook and provider-registered-scope cases as negative fixtures, alongside a plain fixed-connection model as a positive case. + +For `NoMissingTranslationsRule`, use the standard bound `FileLoader` and the booted translator's existing resolution APIs. Only analyze literal `group.item` or `namespace::group.item` keys with a known group file among relevant locales/paths. Track those lang directories and registration providers. `Translator::has()` is useful positive evidence, but false alone is not proof of absence: it returns false for a PHP/boot-added translation whose value equals its key, and intentionally bypasses missing-key callbacks. Confirm negative results with the known group data through existing loader/translator APIs; do not flag identical key/value translations or custom-resolution cases whose absence cannot be established. The group is read with the translator's `getLine()` without an item (the group array; null for a missing or empty group), because `get()` returns a JSON line named like the group instead and runs the missing-key callback. A literal locale the translator's `ensureValidLocale()` rejects is reported as `hypervel.invalidTranslationLocale` at the lookup and not checked further: the translator would throw, and a throwing collected-data rule becomes an internal error that drops all of its errors. Preserve fallback locales, JSON full-key translations, namespace overrides and `addLines()` by reusing framework resolution, not copying it. Stay silent for custom loaders/translators where lookup can do external I/O or has unknown semantics. Add tests for explicit/dynamic locale arguments; extend collectors to carry known lookup arguments rather than assuming every call uses the boot locale. Preserve `translationDirectories` as a usable analysis input without mutating the application's translator. Comment the deliberate adaptation to upstream. + +### Transaction dispatch + +Use statically known configuration from the ported parser, plus statically known dispatch/job overrides. Never use a machine's `.env` value as proof that a deployment is safe or unsafe. Hypervel defaults `after_commit` to true except for `sync` and `database`; a same-connection database enqueue participates in the transaction, but another connection does not. Report only when the queue connection, driver, after-commit and outbox settings are all established; anything unknown stays silent. Upstream assumes Laravel's `after_commit` default and reports dispatches inside `DB::afterCommit()` callbacks. + +- **Walk.** `DB::transaction()`, `DB::connection(x)->transaction()` and `ConnectionInterface` receivers, with a closure or arrow-function literal. Walk its body syntactically without entering nested function-likes or classes: they run later, never or at an unknown time (`DB::afterCommit()` callbacks, registered callbacks); a nested transaction is checked from its own call. +- **Transaction owner.** The full requested connection name, as `ConnectionResolver::connection()` keys its owner and pool: a constant name, with null or `''` meaning `database.default`, enums normalized. Equal names are one owner. `mysql` and `mysql::write` are distinct for a known non-SQLite driver and unknown for SQLite (shared in-memory aliases coalesce) or an unknown driver. A `transaction` frame among the enclosing calls (`CallStack::enclosing()`) is an enclosing transaction with an unknown owner, whatever `afterCommit` frames follow it (they attach to their own connection only); a `usingConnection()` frame makes a default-dependent owner unknown. Either silences the database-queue comparison. +- **Dispatch forms.** `Job::dispatch()`, `dispatchIf()` and `dispatchUnless()` when the called method is Foundation `Dispatchable`'s (a literal condition that fails dispatches nothing; any other condition may dispatch); the global `dispatch()` helper (resolved function identity) with a `new` job or a closure/arrow-function literal (`CallQueuedClosure` facts; its body is not walked); `Bus::dispatch(new Job)`. The job class comes from names only: the walker has no call-site scope, so a variable job is silent. `dispatchSync` and `dispatch_sync` run inline. A non-`ShouldQueue` job or one with its own `queue()` method is silent. +- **Settings**, per setting with the outermost chain call winning: `afterCommit()`/`beforeCommit()`; `onConnection()`/`allOnConnection()`; `viaOutbox()`/`withoutOutbox()`/`onOutboxDatabase()`. `afterResponse(true)` runs synchronously (silent); `afterResponse(false)` dispatches normally. A non-constant argument is unknown. + - After-commit follows `Queue::shouldDispatchAfterCommit()`: `ShouldQueueAfterCommit` unless `afterCommit` is false, else a non-null `afterCommit`, else the connection's `after_commit` or driver default. A deliberate `beforeCommit()` or `$afterCommit = false` default is silent. + - Outbox is `viaOutbox ?? (ShouldQueueViaOutbox || connection outbox)`; an explicit false wins. + - Connection follows `ReadsClassAttributes::getAttributeValue()`: a non-null property differing from its default, else `#[Connection]` (each class, then its direct traits, then its parent; a trait's attribute belongs to the using class) unless a public non-null property is declared below the attribute's class, else the property. `''` selects `queue.default` without routes. Read through PHPStan reflection (`getAttributes()`/`getArgumentTypes()`, defaults through `InitializerExprTypeResolver`), never `ClassMetadataCache` or runtime reflection, which load analyzed code. + - Constructor: the actual constructor (own, inherited or trait), following `parent::__construct()`, parsed with `@cachedRichParser` and cached per class. Writing `$this->{connection,afterCommit,viaOutbox,outboxDatabase}`, a promoted parameter with one of those names, an option method call, or any other `$this`/`self`/`static` call makes the settings unknown, except `Bus\Queueable`'s own `onQueue`, `allOnQueue`, `onGroup`, `withDeduplicator`, `delay`, `withoutDelay` and `through` (native file check, so overrides stay unknown). + - Booted routes and forwards have no provenance: a matching `queue.routes` route with a connection, or any forward when the connection is not explicit, is silent. Forwards are read once through the analysis-only `->call()` pattern. +- **Drivers.** Report a built-in async driver (`background`, `deferred`, `beanstalkd`, `sqs`, `redis`) or `database` on a different known owner, with effective after-commit false, no outbox and no deliberate `beforeCommit()`. Failover delegates to its children, custom driver names are unknown and `sync`/`null` are synchronous: all silent. A replaced built-in connector keeps built-in semantics (Horizon's `redis`). +- **Dependencies.** Property defaults, constructor bodies and constants read by attribute arguments are not exported declarations. Track `SourceFiles::forInstance()` (base class nullable) and the files of the constants the consumed `#[Connection]` arguments read, replaying cached lists for every consumer. Routes and forwards join the shared runtime-input tracking. + +Required positive fixture: source config names a database queue connection different from the business transaction, with `after_commit: false`, no outbox and no explicit safe dispatch. Required negative fixtures: same-connection database, configured after-commit, explicit after-commit callback, outbox, sync, deliberate before-commit and environment-dependent connection selection. Verify queue transport/database connection names at their owning boundaries rather than equating their string identifiers. Warm-edit regressions: a job constructor gaining `$this->afterCommit()`, a parent's `$afterCommit` default, a constant read by `#[Connection]`, and an inherited `handle()` gaining a cancellation check (`BatchableJobChecksCancellationRule`). + +### Verified regression examples + +Keep these as focused tests, alongside all applicable upstream cases: + +```php +value(...[static fn (): int => 42]); +with(...[21, static fn (int $number): int => $number * 2]); +with(callback: static fn (int $number): int => $number * 2, value: 21); +``` + +All three return `42`; upstream's raw positional-argument checks misdiagnose them. A limited query over three rows can return `1` from `limit(1)->get()->count()` and `3` from `limit(1)->count()`. An appended stored attribute with a getter can serialize correctly. A global scope's `extend()` can call `withAttributes()` immediately, making `Model::make()` and `new Model` differ even without a model-defined `make()` method. A collection subclass can intentionally override conversion behavior. Preserve these supported contracts. + +## Hypervel rules + +### Cancellation + +Implement the concrete `docs/todo.md` patterns with PHPStan types and AST/control-flow information: + +- An exact `catch (CanceledException)` that rethrows or stores the cancellation, followed by a broader catch that reports, retries, falls back or records an outcome; callback/I/O/cleanup failures can wrap cancellation before the broad catch sees it. +- `instanceof CanceledException` on a throwable not already narrowed by that exact catch, and `getPrevious() instanceof CanceledException` used to classify cancellation. +- Failure accumulation such as `$failure ??= $exception` where the value is the operation's retained failure and later cancellation would lose precedence. + +Recommend `Coroutine::causedByCancellation()` and `Coroutine::preferCancellation()` at those sites. Use small shared expression/try-block helpers only where more than one rule needs them. Do not construct a general exception-flow interpreter or guess through arbitrary callbacks. + +```php +catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + // Existing ordinary-error handling remains here. +} + +$failure = Coroutine::preferCancellation($failure, $exception); +``` + +Rules (`Rules/Coroutine/`, sharing `CoroutineRuleHelper`; first-class callables are never calls): + +- `ExactCancellationCatchRule`: a catch naming `CanceledException` that rethrows its variable or assigns it (`=`, `??=`) to keep it, followed in the same try by a `Throwable`/`Exception` catch that does not separate cancellation. A catch that ends the operation (`return`) is a terminal boundary and stays silent. Separation is a body of only `throw $var`, a first top-level `if (Coroutine::causedByCancellation($var))` whose branch ends in `throw`, or a body that is one `if` on that guard (either polarity). Silent when every call in the try block is a native wait from a method table checked against the declaring class: Channel/`ChannelInterface` `push`/`pop`/`close`, WaitGroup `add`/`done`/`wait`, `Waiter::wait()`, the coroutine classes' `join()`. A local receiver has its try-entry type when the block only calls methods on it, or the class of one `new` assigned in a top-level statement before every call; any other write leaves it unknown. +- `ExactCancellationCheckRule`: `instanceof CanceledException` on a subject that may be a `Throwable` and is not already known to be a `CanceledException`; a tip gives the null/`Throwable` guard for nullable or mixed subjects. +- `CancellationPrecedenceRule`: `$kept ??= $caught` in a catch body, except in the non-cancellation branches of a top-level guard on the caught variable or after a positive guard whose branch exits. Also silent when the kept variable appears nowhere in the try block nor elsewhere in the catch body and its try-entry type without null is only `CanceledException`. +- Both catch rules skip nested catches that bind the caught variable again: the inner try reports its own catch. +- `NoRawSleepCallsRule`, `NoCoroutinesInTransactionsRule`: see Worker lifetimes and coroutine context. + +Native-only Swoole/Hypervel channel, WaitGroup, join and Waiter operations can retain exact checks when they are the only work in the relevant try block. Do not exempt an entire file or method that also invokes user callbacks or cleanup. Preserve foreign/child ownership boundaries and cleanup-error reporting. Use `tests/Coroutine/CoroutineTest.php` cases for direct/wrapped/nested cancellation, Google's separate service cause, foreign and independent-child failures, and failure priority. Typed domain catches, failure observers and arbitrary ownership propagation remain runtime-review concerns, not purported static proofs. Legitimate exact checks use a local identifier suppression with a reason. + +Conversions in the components repo: an exact catch backed by a broad catch becomes one broad catch opening with a `causedByCancellation()` guard that keeps the exact catch's cancellation-only cleanup and rethrows the same exception; cleanup-failure precedence and `??=` collection use `Coroutine::preferCancellation()`; translators check `causedByCancellation()` (keeping null/`Throwable` guards) before mapping, and a catch that wraps failures in a domain exception rethrows a carried cancellation unchanged. Test-harness cleanup accumulators follow the same `preferCancellation()` policy; only `RunTestsInCoroutine::$capture` keeps the first failure, with a reasoned ignore, so PHPUnit can match the test's expected exception. Terminal reporters that deliberately report cleanup failures carrying cancellation and classification primitives keep exact checks with reasons. Packages gaining a `Hypervel\Coroutine` import declare `hypervel/coroutine` directly. + +### Telemetry cancellation completion + +The checkpoint immediately after the cancellation rules. Today a canceled operation never ends its span: `HttpServerInstrumentation` ends the request span only in `responseSent()`, which `Server` skips on cancellation, and the console, gRPC, scheduler, view, Scout and WebSocket observers return early or are never notified (`ScheduleRunCommand`, `View::renderContentsWithObservers()`). Child spans that ended earlier are still exported (`BatchSpanProcessor`) under a parent that never is, and the active-request counter's `add(-1)` (in `responseSent()`'s `finally`) is skipped, so it drifts upward. OpenTelemetry requires created spans to be ended; ending is separate from recording an error. + +End spans, take state, close scopes, record durations and settle counters at the operation's real unwind boundary, through the existing observers, events and `finally` paths. No coroutine-defer fallback (it gives wrong durations and loses failure context), no fake `ResponseSent` or success event, no lifecycle registry, no dispatcher change to continue past throwing listeners. Disabled instrumentation keeps its behavior. + +**Outcome policy.** + +- A direct cancellation (exactly `CanceledException`) ends the span with status unset and no `error.type`, exception event or exception-context association, and still records duration and settles counters. Docs call this Hypervel's control-flow policy; the OpenTelemetry HTTP client rule is cited only for the HTTP client. A cleanup failure wrapping cancellation is a failure. +- Omit outcomes that never happened (HTTP status and response attributes, gRPC status, console and scheduler exit code, the custom `result` attribute). Keep outcomes already known (a sent response, an exit code, an earlier ordinary failure) and real domain statuses (gRPC `Cancelled`, `DeadlineExceeded`). No invented canceled status or exit code. +- Exact checks: one `AbstractInstrumentation::isDirectCancellation()` with one reasoned ignore ("only a direct cancellation is not a failure; a cleanup failure carrying it is recorded as one"); `OpenTelemetryManager::trace()` keeps its exact catch with that reason; Sentry has its own helper (below). These replace the temporary "skips completion" ignores. +- Completion applies the known outcome before optional enrichment callbacks, so a canceled callback can't drop it: `HttpServerInstrumentation::responseSent()` sets the status before user-context resolution; `RedisInstrumentation::recordCommand()` applies the exception and status before the query-text resolver, with duration and `end()` in `finally`. `CacheInstrumentation` counts the operation before key resolution (drop the old-policy comment). Database spans are retrospective; unchanged. + +**Notification boundaries.** + +- Observer APIs (View rendered observers, Scout `EngineOperationObserver`, gRPC `GrpcOperationObserver`) always finish already-started observers with the throwable, cancellation included. When one throws, notify the rest, then rethrow with `Coroutine::preferCancellation()` precedence. An observer that rethrows the operation's own throwable adds no failure: each loop skips it by identity, because its owner already propagates it. Docblocks say observers receive a "failure or cancellation". +- Existing end events are dispatched on cancellation, carrying it: console `AfterExecute` (already does), `JobAttempted` (below), WebSocket `MessageHandled`, and `ConnectionClosed` even when an earlier close step was canceled. +- WebSocket native callbacks (`onMessage()`, `onClose()`, `deferOnOpen()`) treat a wrapped cancellation like a direct one: it stops the remaining steps and deferred callbacks, completes through the cancellation helpers, and is reported as a real cleanup failure. `MessageHandled` keeps an earlier `MessageReceived` failure as its exception when the handler is then canceled. `reportCallbackFailure()` skips a direct cancellation, so every catch reports through it. +- `JobAttempted` gains a trailing `public bool $canceled = false` (not `interrupted`, which `JobInterrupted`'s signal notice already means), and `successful()` is false when it is set. Worker and SyncQueue dispatch it from the owning coroutine whenever `$canceled` is set, including the kill-mode branch where the body returned and the monitor owns the outcome; their other skipped steps (marker clear, timed-out event, invalid-payload report) stay skipped. `exception` is `timeoutExceededException($job)` when timed out, else the known failure, else the captured cancellation; `forJob()` is unchanged. A listener failure propagates when nothing is unwinding (the body-returned branch), otherwise `FailureReporter::report()` contains it. Foundation's deferred-callback listener and the slow-job handler (`whenJobRunsLongerThan()`) skip `canceled` attempts, keeping today's behavior; ActivityLog and Tenancy listeners keep their normal role and order. User listeners now see canceled attempts; `queues.md` documents it. +- Three new events, named after Laravel's `BatchCanceled` and dispatched only when they have listeners. Each carries `public Throwable $cancellation` (direct or wrapping) and `?Throwable $exception`, the operation's already-known failure. They are raised where an outcome notification was skipped or interrupted, from facts the emitter already holds; no listener-history reconstruction or tracking. Consumers classify: a known `exception` is a failure; otherwise a known outcome (a sent response, a finished task, a queued job) stands whatever the cancellation is, since it came from a notification listener; otherwise a direct `cancellation` is canceled and a wrapping one is the failure. Sentry's request transaction is the exception: a sent response's HTTP status stays its status (below). + - `Hypervel\HttpServer\Events\RequestCanceled extends Event`: `exception` (the base contract) is `Server`'s earlier lifecycle failure (kernel escape, earlier send or listener failure); `response` is set only when its send attempt finished. Covers `RequestReceived` through `Kernel::terminate()` (handshake: `terminateRouteMiddleware`; Reverb has no terminate stage), because Sentry completes in terminate. Dispatched by the http-server `Server`, the WebSocket handshake and Reverb's `HttpServer`. OTel's `completed` flag ignores a notification after completion. + - `Hypervel\Console\Events\ScheduledTaskCanceled(Event $task, Throwable $cancellation, ?float $runtime = null, ?Throwable $exception = null)`: `runEvent()` protects from the `ScheduledTaskStarting` dispatch (a later Starting listener can cancel after telemetry started) through the outcome notifications. `runtime` is set only when the task finished running, so a listener reads `exitCode()` only then (the public fallback can hold a stale run's value). `exception` is the task's failure when cancellation followed it. Telemetry keeps a finished task's outcome. + - An ordinary failure before the task ran (a Starting listener, `registerRunningEvent()`) publishes `ScheduledTaskFailed` without a runtime, then rethrows the original so the run still aborts as upstream; a canceled Failed notification becomes `ScheduledTaskCanceled` with the failure as `exception`. +- A listener failure raised while a cancellation is being notified never replaces it: a direct cancellation is dropped, any other failure is reported through the boundary's never-throwing reporter (`Hypervel\Support\FailureReporter::report()`, WebSocket `reportCallbackFailure()`, Reverb's `FailureReporter`). Applies to `RequestCanceled`, `ScheduledTaskCanceled`, `JobQueueingCanceled` and canceled `JobAttempted`. +- `Hypervel\Support\FailureReporter` holds `PoolErrorReporter`'s unchanged body (skip a direct cancellation, drop a handler-raised cancellation, `error_log` fallback, never throw); `PoolErrorReporter::report()` forwards to it, and its tests moved to `tests/Support`. `FailureReporter::preferCancellation()` selects with `Coroutine::preferCancellation()` and reports the distinct displaced throwable. Reverb's `FailureReporter` keeps its own fallback (it logs the original and the reporting failure) and only gains the direct-cancellation exclusion. +- Displaced failures are reported (not dropped) only on these observer and lifecycle paths: View `renderContentsWithObservers()` and `Factory::notifyRendered()`; Scout's observer loop and final selection; gRPC `GrpcOperationHandle::finish()`, including an observer failure it does not throw; http-server `Server`, Reverb `HttpServer` and the WebSocket handshake for a held ordinary failure displaced by cancellation or by an earlier failure; the queue producer's `enqueueNow()` pair, `notifyBatchMembers()`'s retained ordinary failure when cancellation hides it (one per batch, as today), and the SQS tail's rejection when cancellation displaces it. The other `preferCancellation()` uses keep the documented first-failure convention. + - `Hypervel\Queue\Events\JobQueueingCanceled`: `JobQueueingFailed`'s fields plus a required `bool $sendAttempted`, `bool $queued`, `cancellation` and `?exception` (the send failure when its `JobQueueingFailed` dispatch was interrupted). `queued` is true only when raised from the Queued helper: storage succeeded, so consumers keep the success outcome (Sentry `ok`, OTel no error) whatever the cancellation is, while the cancellation still propagates. It stays separate from `sendAttempted`, because a failed send was attempted but not queued. `raiseJobQueueingFailedEvent()` and `raiseJobQueuedEvent()` raise it from their known facts when their own dispatch is canceled, so `enqueueNow()`, the bulk paths and the outbox share one boundary. + +**Queue producer.** + +- `JobQueueingFailed` gains a trailing `public bool $sendAttempted = true`; the default matches today's counting, and framework call sites pass it explicitly. The flag is set at the actual storage or send call: `enqueueNow()` just before `$callback()`; Redis and Database bulk just before `pushRawBatch()` or the insert; SQS through `$attemptedMessageIds` per chunk, passed per message by `raiseBatchFailedEvents()`; `StagesOutboxJobs` at the outbox insert, per group. +- Cancellation catches notify outstanding members with `JobQueueingCanceled` before rethrowing. `notifyBatchMembers()` continues past a member's cancellation, notifies every member, rethrows the cancellation at the end (with `preferCancellation()`) and still returns the first ordinary failure. `enqueueNow()` still raises `JobQueued` when `handlePayloadPushed()` is canceled, then rethrows; the bulk paths get this through `notifyBatchMembers()`. Storage succeeded, so nothing is reported failed or stored again. +- `finishProducer()` always ends the span and records duration, but counts a sent message only when `sendAttempted` is true (a pre-send failure or cancellation is not an attempt). The counter description becomes "The number of messages the producer attempted to send." A failure or cancellation in the instrumentation's own finalizer completes its started state directly through the shared `completeProducer()` (not sent, no stored state), since no later event can find it. +- Correlation keeps `QueueProducerStateStore`'s payload buckets and UUID index; `createObjectPayload()`/`createStringPayload()` always set the UUID and finalizer rewrites keep it. Docs state the real boundary: a span stays open only for a payload lacking the framework UUID that a later finalizer rewrites. Don't describe custom drivers as unsupported. + +**gRPC.** + +- `Call::__destruct()` finishes an unfinished call with the state's known, nonblocking `finalStatus()`/`finalFailure()` through `resolveStatus()`, using an unknown result only for really abandoned work. `abandonIfIncomplete()` runs in `finally`, inside the destructor's existing no-throw catch. No waits or retries. Canceling one waiter must not end the shared call. +- `GrpcInstrumentation::startClient()` completes its own state when the propagation inject is canceled. `Server`, `GrpcOperationRunner`, `GrpcOperationHandle` and the `BaseClient` start path finish with the cancellation. +- `Server::onRequest()` finishes a canceled call with an earlier terminal failure as its outcome, but the unwinding cancellation keeps precedence there: an observer failure raised by that `finish()` is reported, and a direct one dropped. + +**Sentry** (bounded audit of state-holding integrations). The request transaction finishes through a `RequestCanceled` listener resolving the scoped `Tracing\Middleware`. The scheduler and queue-producer integrations handle the new events. The consumer finishes the active, unfinished job span on a cancellation `JobAttempted`, preserving nested-job scope, reporting and already-finished spans. Direct cancellation leaves the status unset (sentry-php has no `cancelled` factory, and its constructor is private); wrapped and earlier failures keep the failure status, except on a request whose response was sent: its HTTP status stays the transaction status, as in `terminate()`, and the earlier failure is reported as its own event. Overriding it only when a later listener is canceled would make the same request report differently. One `WorksWithSpans::cancellationStatus()` holds the exact check. Cron check-ins: `ScheduledTaskFailed` and a failure-classified `ScheduledTaskCanceled` send `error` for the task coroutine's unfinished check-ins (a failed or canceled run can skip the after callbacks that finish them), listening whether or not spans are recorded. A direct cancellation sends none (`CheckInStatus` has only ok/error/inProgress); `src/docs/sentry.md` says Sentry then reports the run timed out because its final check-in never arrived. + +**Docs and tests.** + +- Rewrite `src/docs/opentelemetry.md`'s cancellation paragraph, its gRPC "no false completion" sentence, the queue paragraph on UUID-less rewritten payloads and the counter description. Document the new events beside their siblings. +- Convert the existing "no false completion" OTel tests into completion assertions. Regressions: a canceled request's span ends and the active-request counter returns to zero, including cancellation in `terminate()`; a Starting-listener cancellation; an interrupted Failed notification keeps the task or send failure, and an interrupted Queued notification keeps the publish success; a pre-send cancellation or failure is not counted as sent; post-storage cancellation still raises `JobQueued`; batch notification continues past a cancellation; Call destruction of abandoned streaming work, of an already-completed unconsumed result, and with a throwing observer still releasing native resources; a canceled Redis resolver keeps the command's outcome (successful and failed, direct and wrapped); a canceled consumer attempt ends its span and restores the scope (direct, wrapped, timeout, after a failure); an instrumentation finalizer failure records duration; a wrapped cancellation stops WebSocket message, close and open work; Sentry request, scheduler, producer and consumer completion; one per changed observer path, including an observer rethrowing the operation's throwable. Listener and observer assertions record events and assert after the call, because a failing assertion inside a reported listener would only reach a reporter. + +### Worker lifetimes and coroutine context + +Use actual container semantics: unbound buildable classes auto-singleton unless intrinsically fresh; explicit registrations, `Scoped`/`Singleton`, `Transient`, `SelfBuilding`, execution-scoped constructor attributes and contextual parameterized construction change the lifetime. Do not classify every mutable object as a singleton or every singleton mutation as unsafe. + +Generic writes on shared objects are not reported: deliberate worker state (driver and store caches, lazily resolved config, registries, pools, worker loops) is the norm and no exemption marker exists. Detection covers only the cases below; legitimate exceptions take reasoned local ignores. + +**Architecture.** One `Rules/Coroutine/WorkerStateRule` on `CollectedDataNode` reports all four identifiers. +- Registered unconditionally with the four switches injected (`Conditional` cannot express OR); it returns at once when all are off, and the collectors decline work too. +- Collectors hold only boot-independent facts, because PHPStan caches collected data per file: `WorkerStateWriteCollector` (`PropertyAssignNode`), `WorkerStateCallCollector` (`CallLike`: resolutions, route actions, direct `$this->method()` calls, config calls), `WorkerStateClassCollector` (`InClassNode`: `#[Singleton]`, `ShouldQueue`, promoted captures), `WorkerStateParameterCollector` (`VariableAssignNode`: reassigned state and config parameters) and `WorkerStateConfigOffsetCollector` (`Stmt`: config offset set and unset statements). +- Booted facts apply only in the rule and are read once. Never call `isShared()`/`isScoped()`/`class_exists()` or construct anything from analyzed code: class facts come from PHPStan reflection, attribute arguments from its constant evaluation. + +**Roots** are walked for dependencies; only shared ones are receivers. They are registered bindings and instances, booted route controllers and route middleware, kernel and router middleware lists, class-string listeners, Bus-mapped handlers, collected parameterless constant resolutions, `#[Singleton]` classes and jobs. A job is a `ShouldQueue` class using the Bus `Dispatchable` trait (inherited uses count) that is not a mapped command; its `handle()`/`__invoke()` parameters are injection roots. Queued listeners and mapped commands are called directly. Handle-only dependencies of trait-less jobs are not discovered; the docs say so. + +**Dependency paths** follow the entry point's real resolution: +- A `ContextualAttribute` comes first; its value is unknown. +- Constructors follow `Container::resolveClass()` with the built class as the contextual consumer: a default when unbound with no contextual binding of that exact name, a class-string contextual concrete followed as `make()`, a contextual closure unknown. +- Job handlers follow `BoundMethod` with the job as consumer: a default whenever the class is unbound. +- Controller actions and route closures follow `ResolvesRouteDependencies::transformDependency()`. A defaulted parameter is never resolved. A class parameter is skipped only when a route binding can supply it: any class when the route has an explicit binder, or a `UrlRoutable` named like a route parameter or its snake case (`ImplicitRouteBinding::getParameterName()`). A same-name scalar route parameter does not stop injection. Booted route closures are read for parameter names, types and attribute names only. + +**Effective lifetime, explicit first.** An instance is shared; its constructor arguments are known only when its binding built the same class. A binding uses its class-string concrete as scoped, shared or fresh; an unknown factory is never executed and stops the walk; a fresh binding's inner concrete uses only an explicit lifetime. Unbound: `#[Singleton]` shared; `#[Scoped]` scoped; `SelfBuilding`/`Transient` fresh; an execution-scoped constructor attribute scoped (framework attributes decide by class, `Cache` by its constant `memo` argument, any other runtime decision is unknown); an interface, abstract or missing class, or `#[Bind]`/`#[BindWhen]`, unknown; otherwise an auto-singleton. The walk is memoized once per run. + +**Contexts.** +- Booted middleware `handle()`/`terminate()` and controller actions, resolved on the class the container builds (a bound subclass included), keyed by the declaring method and recording whether any handling instance is shared. +- Helpers those methods and job methods call on their own instance. The call collector records direct, named `$this->method()` calls by caller (class and method), not inside nested functions and not first-class callables. `WorkerLifetimes` propagates each receiver's context with a worklist over receiver and declaring method (a visit as shared covers a later unshared one, not the reverse; edges indexed by caller and deduplicated). A call resolves to the caller class's own private method first, as PHP binds private calls to the calling scope, otherwise to the receiver's implementation; static methods and `__construct` are skipped, and `flushState()`/lifetime-tagged callers record no calls. Job roots are every queued class's job methods, shared only when the container shares that class. Propagated contexts go through the same `addContext()`, so write and config checks read one definition; config parameter provenance is unchanged. A helper reached only from a constructor or boot path gets no context. The write collector therefore keeps writes in any non-static, non-constructor instance method. +- Route closures and arrow functions passed as a route method's `action` argument (`Router`, `RouteRegistrar` or the `Route` facade). The call collector records the action's source range and the ranges of functions and classes nested in it; a collected position inside the action but outside those ranges runs in it. This is collected, so it also covers route files the analyzed boot does not load. +- `ShouldQueue` `handle()`/`__invoke()`/`failed()`, and `FormRequest` methods. + +Functions nested in a context are not contexts. Ordinary listeners are not contexts, because boot events legitimately configure state. + +**Known request/operation state**: a definite (null-removed) Symfony `Request` or PSR server request, Symfony or PSR response, `Authenticatable`, Symfony `UploadedFile` or Eloquent `Model`. Excluded: `Session` (`session.store` keeps its data in `CoroutineContext`) and `Route` (route registries hold routes). + +**Skips.** Virtual properties (for example `TransformsRequest::$keyBytes`); `flushState()`; methods and constructors whose docblock has a line starting `Boot-only`, `Tests only` or `Boot or tests only`, single-line docblocks included (Passport writes "Boot-only when setting."). + +1. `hypervel.workerSharedState`: a shared receiver keeps known state through a property write or a promoted or directly assigned constructor parameter, or writes a `$this` property inside a context whose handling instance is shared. An inherited booted method is judged by the instances that handle it, not by every shared subclass. + - A constructor parameter is exempt only when the write comes before the constructor's first reassignment of it and, for every shared build of that constructor, the walk proves the default or a fresh instance of a known class, with no contextual attribute and no contextual binding for the actual consumer. Shared, scoped or instance bindings, closures, unknown factories, `SelfBuilding` and instances without a binding are not exempt. +2. `hypervel.staticStateMutation`, `hypervel.runtimeConfigMutation` and `hypervel.unboundedWorkerCache`. + - `staticStateMutation`: a static property write inside a context, or a static property storing known state anywhere outside the skips. + - `runtimeConfigMutation`: a config write inside a context: `config([...])`, repository `set`/`push`/`prepend`/`offsetSet`/`offsetUnset`, and statement-level offset assignments (compound included) and `unset`. The receiver is `config()`, the Config facade, a resolution without parameters, or with provably empty ones, whose name the booted alias map resolves to `config` (arguments bound by their real names: `abstract`, `resolve()`'s `name`, `get()`'s `id`; any unpacked argument declines) (no fixed name list; collectors keep the requested names), or a parameter the container injects into a controller action, route closure or job `handle()`/`__invoke()`: typed as a class the alias map resolves to `config`, without a default or contextual attribute; for a job, a contextual binding's replacement must also resolve to `config` (`Container::call()` makes the job the contextual consumer; route actions have none). Only writes before the parameter's first reassignment count; parameter facts keep the earliest reassignment position per function key (`class::method`, or `file#start` for a route closure, since closures in different files can share an offset). Locals and properties are not the global repository. The contract is not `ArrayAccess`, so PHPStan records offset writes on a contract-typed variable as variable writes, which count as reassignment. + - `unboundedWorkerCache`: an offset set on static or receiver array storage that is not a constant array at the write (storage the method just reset holds only its own entries). The key must directly read `Request`/`Authenticatable`, `request()`/`auth()` or the `Request`/`Auth` facades, and its type must not be finite or an integer range bounded at both ends; bounds are checked before `getFiniteTypes()`. No taint tracking; `WeakMap`, `ArrayAccess` and unknown cache objects stay silent. The message says the storage can grow; intentionally bounded storage takes a reasoned ignore. + - **Arbitration.** One diagnostic per write, among the enabled identifiers: `unboundedWorkerCache`, then `workerSharedState`/`staticStateMutation`. + - **Cache correctness.** Under bleeding edge's `reuseCollectedDataRuleErrors`, the rule calls `trackClassDependency()` for every class whose lifetime or injection metadata it reads. `WorkerStateDependencyTest` edits a dependency's constructor and a lifetime attribute with the candidate file unchanged, with and without error reuse. Booted bindings, contextual bindings, routes, middleware, listeners and Bus maps use the shared runtime-input tracking. + - **Runtime fix.** `Route::shouldCacheControllerOnRoute()` checks `isScoped()` (false), then `isShared()` (true), then any other bound class (false), and otherwise caches unless the class is `Transient` or `SelfBuilding`. Fresh controllers keep the coroutine-local path, so middleware and dispatch still share one instance. `RouteControllerCachingTest` covers both classes and explicit-singleton precedence. +3. Detect supported child-coroutine spawning APIs inside transaction callbacks when work is being moved off the transaction-owning coroutine. Explain that pooled database ownership belongs to the parent and the child does not inherit its transaction. Preserve established transaction-preserving test infrastructure and calls that do not actually spawn children. `NoCoroutinesInTransactionsRule` covers `go`/`co`/`parallel`/`wait`, the coroutine classes' `create`/`createOwned`/`fork`/`forkOwned`, `Parallel::wait()`, `Concurrent`/`WaitConcurrent` `create`/`fork`, `Waiter::wait()` and `CoroutineDriver::run()`, plus `run()` on the Concurrency facade or on `ConcurrencyManager` (its `@method run` makes the manager, not the mixed-in `Driver` contract, the declaring class) only when static config selects a default instance whose driver is definitely `'coroutine'` (an absent instance entry means its name; a present one needs its `driver` key), and the booted manager is exactly `ConcurrencyManager` with no custom creator for it. `driver()` on the facade and manager returns the known driver class by the same resolution (`ConcurrencyDriverHelper`, for a constant or enum name, with null or `''` meaning the default), else the `Driver` contract, so `driver('coroutine')->run()` reports. Only a sync driver runs tasks in place; tasks of any other `Driver::run()`, and of a facade or manager `run()` not known to use the sync driver, may run elsewhere and are a callback boundary. The innermost relevant enclosing call (`CallStack::enclosing()`) decides: `transaction` reports, `afterCommit`, a spawn frame or such a `run()` is a callback boundary. Evaluated, non-closure arguments of an in-transaction boundary call are searched for spawns, reported on their own lines. A known-empty task list (found by name or first position) is silent. +4. Diagnose raw source `sleep()`/`usleep()` where the documented replacement is `Hypervel\Support\Sleep`. Preserve real-time test/infrastructure waits and other explicitly justified timing contracts. The components repo keeps the `Sleep` and `Coroutine::sleep()` implementations, `LazyCollection`'s fallback without Support, prompts' animation timing, `StripedLock`'s spin backoff, `CurlStreamingBody`'s libcurl polling and Horizon's process supervision with reasons; `BenchmarkCommand`'s pause uses `Sleep`. + +5. `RedisConnectionBoundMethodRule` reports a call that reaches `RedisProxy`'s stock `__call()` with a name in its `CONNECTION_BOUND_METHODS` (read once in the proxy's scope, not copied), which always throws there: `Redis::connection()->client()` and the like, on `RedisProxy` or `RedisManager` receivers. The `@mixin RedisConnection` chain otherwise accepts them; the facade already rejects them. It skips a native method accessible in scope (the proxy's own protected `getConnection()` calls, a public subclass method) and declines a receiver that overrides `__call()`, or for `RedisManager` `connection()`. The message names the held-connection replacement (`Redis::withConnection()`/`withPinnedConnection()`). + +Use collectors only for cross-file binding facts needed by diagnostics. Derive known runtime registrations through existing bootstrap/reflection facilities when available. Unknown ownership must not produce a confident leak claim. Fix genuine violations and cover intentional patterns as negative fixtures. Do not add a separate policy framework, service registry, deep interprocedural data-flow engine or runtime instrumentation. + +### Legacy authoring policy and safe fixes + +Add configurable, default-enabled recommendations for the owner's modern conventions: + +- Model `scope[A-Z]` methods -> protected `#[Scope]` methods (`NoLegacyModelScopesRule`, no fix). Match model inheritance/traits, not unrelated methods such as route `scopeBindings()` or Passport `scopeIds()`, using the visibility rule's legacy-scope recognition. Skip methods a parent or interface declares, and stay silent when the model already has a native method with the target name: the legacy prefix is then the only way to keep the scope (`DatabaseNotification`'s `read()` beside `scopeRead()`). Existing public APIs (`DatabaseNotification`, Telescope's `EntryModel`) and reusable public traits (permission `HasRoles`/`HasPermissions`, whose `role()`/`permission()`/`team()` would collide with consumer models' own methods, which silently win over trait methods) keep their legacy scopes with reasoned identifier ignores; the docs list both as legitimate exceptions. +- Model configuration property declarations -> the attributes listed in `docs/todo.md` (`NoLegacyModelPropertiesRule`, `hypervel.legacyModelProperty`): table/connection/key/key type/incrementing/timestamps/date format/fillable/guarded/hidden/visible/appends/touches/refreshes. Base `Model` and its storage traits declare runtime storage and are never reached (see Legacy property comparison). Preserve runtime setters/assignments and properties without equivalent attributes (casts, eager relations, pagination). +- Pipe-separated validation lists -> arrays (`Rules/Validation/`: `NoPipeSeparatedValidationRulesRule` on calls, `...ReturnRule` on `rules()` returns, `...AttributeRule` on `#[Rule]`, sharing `ValidationRuleHelper`). Report only strings the runtime splits, once, where they are written: + - Validator splitting: a string rule *value* always splits (`explode('|')`, regex included; empty segments are ignored); string elements of an array value never split, so `['regex:/a|b/']` (normal and wildcard keys) is the legit form. `ConditionalRules` splits its string rules. Data splits strings at any depth unless the rule name (lowercased, before `:`) is `regex`/`not_regex`/`notregex`. + - Surfaces, parameters bound through PHPStan's argument mapping (named arguments; first-class callables are not calls), with call names filtered before reflection. Maps (each item value of an inline array literal is a set): `rules` of the Factory contract's `make()`, Factory `validate()`, `Validator::__construct()`/`setRules()`, `validator()`, the Validator facade, `ValidatesRequests::validate()`/`validateWithBag()` (native file check) and `validateWith()`'s array, the Request `validate()`/`validateWithBag()` macros, MCP `Request::validate()` and AI `Tools\Request::validate()`; returned array literals of a `FormRequest`'s `rules()`. Sets: `Validator::sometimes()`'s `rules`; `rules`/`defaultRules` of `Rule::when()`/`unless()` and `new ConditionalRules()`. `Rule::anyOf()`/`new AnyOf()` items: a map when the item is not a list (`Arr::isAssoc()`), else a set. Prompts' `validate` (`Hypervel\Prompts\` functions, `FormBuilder` methods, `Prompt` constructors): a string is a set, a non-list array's first value a set (`validatePrompt()`). Data: returned array literals of a `ValidateableData` class's `rules()` (values recurse as Data sets) and the `Rule` attribute or constructor arguments (Data sets). List-versus-map follows the literal's type; an uncertain shape or unpacked item stays silent. + - A set value is reported when every alternative of its type is a constant string and one splits (constants, ternaries), or when it is a concatenation or interpolation with a literal `|` in a direct part. For Data such a dynamic string reports only when its rule name is known to be non-regex (the known leading text contains `:`, or a `|` before any `:`). + - Not reported: pipes in Validator array elements (the unknown rule fails loudly at runtime, and regex needs that position), closures, `Rule::forEach()`, rules built in variables or helpers (a type-based map check would repeat a parent `rules()` literal at every `array_merge(parent::rules(), …)`), internal `addRules()` and `hasRule()` (a query). `BenchmarkValidationCommand`'s scenario rules are not literal entry arguments, so they stay as measured workloads. + - Fix: only a `String_` is replaced, by an array of its exact segments (runtime-equivalent; Data flattens nested arrays, and `#[Rule([...])]` is its documented form). No fix when a segment is a regex rule, empty or untrimmed. The message names the replacement when fixable; otherwise a tip says a regex containing `|` must be its own array element. + - Convert the flagged components repo source (Fortify, Reverb, MCP) and doc examples in the same slice. + +Add `Rules/Queue/NoLegacyQueuePropertiesRule` under the default-enabled `noLegacyQueueProperties` switch. Follow Laravel 13's [documented queue attributes](https://laravel.com/framework/docs/13.x/queues) and Hypervel's actual attribute consumers. Diagnose fixed configuration-property declarations on recognized queued jobs, listeners, mailables, notifications and broadcast events only where that object's dispatch path reads the equivalent attribute; an unrelated class with a property named `queue` is not a match. This is an authoring-policy check, not a claim that supported properties are broken. + +| Fixed property declaration | Preferred queue attribute | +|---|---| +| `$tries` | `#[Tries(...)]` | +| `$timeout` | `#[Timeout(...)]` | +| `$maxExceptions` | `#[MaxExceptions(...)]` | +| `$backoff` containing integer seconds or an integer sequence | `#[Backoff(...)]` | +| `$uniqueFor` on a unique job, listener or broadcast event | `#[UniqueFor(...)]` | +| `$debounceFor` on a debounced job/listener | `#[DebounceFor(...)]`, retaining any inherited `maxWait` | +| `$connection`, `$queue` | `#[Connection(...)]`, `#[Queue(...)]` | +| `$delay` containing integer seconds | `#[Delay(...)]` | +| `$failOnTimeout = true` | `#[FailOnTimeout]` | +| `$countCrashesAsExceptions = true` | `#[CountCrashesAsExceptions]` | +| `$deleteWhenMissingModels = true` | `#[DeleteWhenMissingModels]` | + +Objects, first match: a `Mailable` subclass, a `ShouldQueue` `Notification`, a queued `ShouldBroadcast` event, any other `ShouldQueue` class (job or listener). Only public declarations, and only settings the object's dispatch path reads through attributes; a method computing a setting on that path (such as `tries()`, `viaQueue()` or `broadcastQueue()`) silences it. Null defaults mean no fixed setting; false flags can override inherited markers and are never replaced. `#[DebounceFor]` keeps the nearest attribute's `maxWait`, which is read from the attribute alone. + +#### Legacy property comparison + +- Each rule simulates its settings' runtime operations through PHPStan reflection (`Model::initializeModelAttributes()` and the concerns' initializers; `ReadsClassAttributes::getAttributeValue()`, with broadcast queue/connection read property-first) over the class's current ancestors and attributes. Suggest removal when the value is unchanged without the declaration (never for a trait, whose other users may rely on it), else the attribute when the value is unchanged with it, else stay silent. The simulated edit is exactly the stated one: a placed attribute replaces only the class's own attribute of the same class. +- Only the class's own declarations. The queue rule also checks a direct trait's, reported at the trait's line in the context of each class using it (one ignore covers them all), when no other direct trait declares the property and the default means the same in the trait, where `self` and `__CLASS__` name the trait. The model rule skips trait declarations, which PHP forces to match the inherited declaration. Nested traits stay silent, as attribute lookup reads only direct traits. +- Also silent: no default, promoted properties, non-constant or unrepresentable values, and declarations the effective constructor may change: an assignment (including compound, reference or through an array dimension), a promoted parameter, or a call on the instance to the rule's framework setters (`Queueable`'s connection/queue/delay setters; model setters such as `setTable()`, `mergeFillable()` and `makeVisible()`). `Support\ConstructorParser` finds the effective constructor by source position, since a trait method imported as the constructor keeps its own name, and follows `parent::__construct()`. Other method bodies, including trait initializers, are not read; the guide states this limit. The transaction rule shares the constructor lookup. +- A model has one combined `Table` candidate, holding the nearest `Table`'s fields plus the replaced name/key/key type/incrementing values (a `null` key is `key: false`), verified against all six `Table`-read settings; any mismatch silences its `Table` suggestions. +- Messages print the declaration's source expression when the value is unchanged, else evaluated constants (`\Enum::Case`). Tips say subclasses can take precedence differently, so review them and keep the property where the hierarchy cannot be expressed; merge, `Table` and `DebounceFor` tips add that a subclass's own attribute must repeat inherited values. Link the Static Analysis guide. Track `SourceFiles::forInstance()` and the read attributes' argument files. No automatic rewrite. +- Focused rule fixtures cover the mappings, removals and each silent case above, including own and inherited constructors. + +Before applying the queue rule's connection/queue/delay conversions, fix the existing chain precedence gap. `PendingChain::dispatch()`, `Queueable::dispatchNextJobInChain()` and `ChainedBatch::attachRemainderOfChainToEndOfBatch()` read only job properties, so chain defaults can overwrite explicit attributes before Dispatcher resolves them. Resolve the job's effective setting using the existing attribute/property precedence before applying chain defaults. Preserve explicit runtime overrides, inheritance, the tested empty-string fallback and valid `'0'` identifiers; preserve a job's zero delay. Reuse the existing attribute-reading logic at framework-owned boundaries without adding lookup methods to user jobs, duplicating precedence rules or creating a registry/cache: one `Bus\ChainDefaults::appliesTo()` lookup (`ReadsQueueAttributes`; a null or `''` effective value leaves the setting to the chain) serves all three sites. Laravel has the same gap, so add a brief comment at each changed precedence decision explaining that the job's own attribute or property beats the chain default. Cover first and subsequent jobs, the remainder after a chained batch, and first-job delay in the relevant existing test files. + +Keep fake dispatch consistent with those paths. Dispatchers select a connection through the queue factory's `connection($name)`, which `QueueFake` ignored, so pass-through lost a Bus job's attribute or route connection and every connection a queued listener or broadcast selects (their wrapper jobs carry none). For a non-null name, `connection()` returns a `QueueConnectionFake` holding the parent fake and the enum-normalized name; recording, callbacks, dispatch locks and assertions stay on the parent through its public `pushToConnection()`/`bulkToConnection()`, other fake methods forward to it, and `getConnectionName()` stays `'fake'`. A selected name wins, including `''` and `'0'`. An unbound push (a direct `Queue::push()`) reads the job's effective `Connection` attribute or property, else the default, with no route lookup, because the real manager sends direct pushes to the default connection. `PendingChainFake` overwrote first-job settings unconditionally through `allOnConnection()`, `allOnQueue()` and `delay()`; it shares `PendingChain::prepareFirstJob()` so the fake preserves both job properties and attributes while still dispatching through its supplied fake bus. Add focused regressions for selective queue-fake forwarding (a dispatcher-selected route, a queued listener's connection, interleaved adapters sharing one history, bound bulk with delays) and fake-chain first-job precedence. These are runtime correctness fixes required before converting consumers, not analyzer workarounds. + +These diagnostics enforce authoring policy; messages must not say a currently supported API is unavailable. Keep current legacy type inference/runtime behavior until its removal is explicitly approved. Do not automatically execute the model and validation runtime-removal todos as part of importing an analyzer. A runtime removal needs a concrete, owner-approved change covering source, consumers, fixtures, error messages and porting documentation together; its cached boot checks must not add work per hydrated model. This boundary preserves supported APIs while delivering the requested static guidance now. + +Retain PHPStan's autofix integration for verified local transformations, including unambiguous pipe lists and corrected `value()`/`with()` calls with preserved evaluation effects. Test fixes by reanalysis and runtime equivalence where relevant. Do not offer scope-renaming or model-property-to-attribute autofixes: cross-file direct calls and inherited precedence cannot be established by the local fix. Give an exact replacement in the diagnostic instead. Exclude `regex`/`not_regex` cases from mechanical pipe splitting. Audit other retained upstream fixes by the same criterion. Never run `--fix` automatically during ordinary analysis. + +## Testing and implementation sequence + +### Harness and fixture ownership + +Port upstream rule/type/reflection/unit/integration tests under `tests/PHPStan`, preserving relative upstream placement and all applicable cases. Use PHPStan's `RuleTestCase` and `TypeInferenceTestCase` for rule and type-inference tests, and `PHPStanTestCase` for other PHPStan tests needing its service container, as listed in `AGENTS.md` under Base classes. These analysis tests do not need Hypervel's coroutine wrapper or a custom container harness. + +`tests/PHPStan` runs as its own suite through root `phpunit.phpstan.xml.dist`, excluded from `phpunit.xml.dist`. PHPStan `require_once`s bootstrap files once per cached container, while `AfterEachTestExtension` clears the container instance, facades and macros after every test, so the analyzer app would vanish after the first test (and before later files' data providers in a reused ParaTest worker). The analyzer config omits that extension and keeps the main config's applicable failure/timeout settings. Its `memory_limit` is 2G, because PHPStan's test case keeps a container per config set and the analyzed fixtures stay cached, so a worker grows with the files it runs; subprocess PHPStan runs pass `--memory-limit=1G` rather than inherit php.ini's limit. Add `composer test:phpstan` (ParaTest with that config) to the `check`/`fix` scripts and `tests.yml`; run one class with `vendor/bin/phpunit -c phpunit.phpstan.xml.dist --no-progress `. Do not add reboot subscribers, cleanup exemptions or a container simulator. + +The process keeps one shared analyzer app. Its PHPUnit bootstrap, `tests/PHPStan/phpunit-bootstrap.php`, loads the extension's bootstrap before any test, so the app's error and exception handlers predate PHPUnit's per-test handler checks (`failOnRisky`); unit-style analyzer tests extend `Hypervel\Tests\PHPStan\TestCase`, which keeps those handlers rather than flushing them through `HandleExceptions::flushState()`. Tests that deliberately change its config, paths, bindings or macros restore their own changes; no blanket flush. Do not run Hypervel Testbench test cases in this process: their teardown flushes the shared app. Boot, cache and install checks run as subprocess fixtures with their own bootstrap/autoload; source runtime fixes stay in their owning framework suites. Every `setUp()` override calls the PHPStan parent initializer. Drop upstream `NoEnvCallsOutsideOfConfigRuleTest::setUp()`/`overrideConfigPath()`: it skips that initializer and permanently changes the app config path, which its explicit `configDirectories` never consume. Verify multiple tests and files in one reused process with existing macro/container cases (a focused ParaTest `-p1` run); no generic lifecycle test framework. + +Dedicated rule fixtures intentionally contain invalid source and must be analyzed only by their harness with expected diagnostics. Preserve upstream `data/` placement where it supports syncing; put new Hypervel-only reusable support in `Fixtures/` per repo conventions. Port the upstream fixture app into package-owned test fixtures, loaded only for analyzer tests; reuse Testbench's disposable runtime for its assets instead of modifying its committed skeleton, writing into vendor, or always-loading package routes. Keep upstream's `App\`, `Database\Factories\` and `Database\Migrations\` namespaces: `Factory::resolveFactoryName()` derives factories from the app namespace that `Application::getNamespace()` reads from the Testbench skeleton (`App\`). Register those prefixes on Composer's existing loader from `tests/PHPStan/phpunit-bootstrap.php` before PHPStan reflection starts; PHPStan's `AutoloadSourceLocator` resolves them through `ClassLoader::findFile()`. No root `autoload-dev` mapping for these fixture-app prefixes, and no custom autoloader. Root `autoload-dev.classmap` lists `tests/PHPStan/{Integration,Rules,Rules/Queue,Type,Unit}/data`: PHPStan's test source locator discovers functions and inline helper classes declared in analysis fixtures through Composer classmap directories, without executing the fixtures, as upstream's `tests/` classmap does. `exclude-from-classmap` drops `tests/PHPStan/**/*-fixed.php`: a fixer's expected output declares the same classes as its input, so either copy could otherwise win the classmap. Rebuild Composer's autoloader after adding fixture classes and before verification. Keep existing `types/` fixtures as public typing regressions and update moved references. + +Keep type fixtures that intentionally exercise still-supported APIs. In `phpstan.types.neon.dist`, set `noLegacyModelScopes`, `noLegacyModelProperties`, `noLegacyQueueProperties` and `noPipeSeparatedValidationRules` to `false`, with one comment that these fixtures assert supported types rather than authoring policy. Also set `checkModelMethodVisibility` and `noUnnecessaryEnumerableToArrayCalls` to `false` in this harness: `CustomBuilderForwarding.php` deliberately declares public scopes, and Collection/LazyCollection fixtures assert conversion return types. These preferences retain their normal defaults in source/consumer analysis and their dedicated `RuleTestCase` coverage. Preserve type assertions; do not delete compatibility cases. For upstream always-registered diagnostics without a switch, use a local identifier suppression with a fixture-specific reason only at the intentional call (for example the existing `value('foo', 42)` and `with(new User)` helper fixtures). Do not invent public switches or a second rule-registration system solely for the harness. + +Exclude mirrored analyzer `data/` directories and any intentionally preserved fixture-app files from `.php-cs-fixer.php` before running `lint:fix`; its current finder covers all tests and would rewrite deliberate syntax and error line numbers. Keep test classes and ordinary Hypervel support files formatted normally. Format new Hypervel-only fixtures before recording expected diagnostic lines, and rerun affected tests if a formatter changes those files. Convert bare `@phpstan-ignore-line`/`@phpstan-ignore-next-line` comments in ported source to accurate identifier-specific ignores with reasons, or fix the underlying type; the package itself is analyzed with the new ignore policy. + +Do not add repository-housekeeping rules for test method title docblocks or missing test return types to this reusable package. General `tests/` analysis remains excluded; the separate formatting/convention work in the todo is unchanged. Follow the current conventions for all tests touched by this port. + +### Work order + +1. Inventory all upstream source, stubs, tests and current Hypervel extensions. Make one remaining-file checklist recording copy, move, merge or justified omission; do not duplicate it in this plan. Set up metadata, dependencies and the test harness. +2. Port bootstrap/Testbench/error reporting and verify app/package startup, optional components, AOP and worker behavior before relying on runtime discovery. +3. Move existing extensions and port the type engine, stub refinements, config/schema parsers and cache dependencies. Run each changed test class immediately. Keep public typing fixtures passing as registrations move. +4. Port retained rules with the corrections above, then add Hypervel rules and safe fixes. Include every applicable upstream test and the concrete regressions; do not weaken expected types/errors to get a green run. +5. Integrate the components repo, skeletons, private packages and affected private apps. Fix actual source defects and stale suppressions revealed by the new analysis; do not blanket-disable the defaults or generate a baseline to bury them. +6. Complete documentation, split/install tests, self-review and the assigned peer code review. Correct findings and rerun only affected checks. No implementation checkpoint permits commits or publication without owner authorization. + +### Verification matrix + +| Surface | Required checks | +|---|---| +| Rule behavior | Positive and negative fixtures, inheritance/traits, aliases, named/unpacked arguments, nullable/union types, dynamic unknowns, stable identifiers, toggles enabled/disabled and safe-fix output/reanalysis. | +| Type engine | `assertType` fixtures for model properties/accessors/relations, scopes/custom builders, factories/collections, auth, app/container keys, helpers, config, console, macros, views and public custom PHPDoc types; native contracts remain valid. | +| Current inference gaps | Baseline fixtures for `auth()->user()` (currently a false Factory `method.notFound`), `$model->relation`/`$model->column` (`property.notFound`), `app('cache')`, `$request->user()`, `Arr::get()` and `->map->getKey()` (currently mixed in the investigated examples), `collect([1, null])->filter()` (retains null), and a known model's `toArray()` (currently `array`). Assert the expected precise type for a fully specified fixture while preserving uncertainty for dynamic/custom cases. | +| Schema/config | Real supported database dump/migration fixtures, custom-driver uncertainty, declared-property precedence, static env casts, nullable values, package/application merge ordering and typed getters. | +| Cache | Keep upstream dependency/cache tests. Check unchanged warm-run reuse for an app and a package. Add one warm-versus-cold regression per new or changed invalidation mechanism: file content, directory membership, provider input behind a runtime fact, macro/mixin source, a pre-boot global input, and same-second migration edits. Reuse existing coverage and combine fixtures where they exercise the same mechanism; do not multiply each input by every possible edit operation or add tests for unchanged locking. | +| Boot | Skeleton app, the components repo through Testbench, standalone package with `testbench.yaml`, custom vendor directory, runtime-requirement/failure diagnostics, AOP/class replacements and optional Head/Scout absent/present. Verify each process's Testbench copy is removed on normal exit and copies left by terminated workers are purged on the next run under the existing ownership rules. No accidental server start or committed-skeleton writes. | +| Consumer installation | A clean standalone Composer install of the split package with own dependencies; central and deprecation extensions each load exactly once with manual includes and with installer discovery. Reuse this fixture to assert a known deprecated call produces one upstream-identifier diagnostic. Required classes/stubs are present without the components repo's root autoloading. Do not duplicate the dependency's own rule test suite. | +| Runtime behavior | Focused existing framework tests for every source typing/correctness change. No weakened contracts or runtime casts/branches solely for PHPStan. | +| Real projects | The components repo, base/maintained skeletons, both private monopackages and affected private apps. Adapt upstream's e2e workflow; public CI uses public/sanitized fixtures. Private sources and their baselines are never copied into the public repository. | + +Use bounded sequential commands from the correct repository directory. Run `./vendor/bin/phpunit --no-progress ` (analyzer tests: `-c phpunit.phpstan.xml.dist`) immediately after each test file; use `composer test:phpstan` for multiple analyzer files. For multiple affected framework classes use `composer test:parallel -- --processes=4` with relevant selectors. Run `composer test:testbench` if Testbench changes. At a coherent completion boundary run `composer analyse` (source, `types/`, skeleton checks); it requires the skeleton's own dependencies. Run `composer lint:fix` before scheduled review. Do not run PHPStan on general `tests/` or pass source/test paths to `phpstan.types.neon.dist`. Keep parallelism inside the chosen tool, not overlapping verification commands. Leave full matrices to CI unless a concrete unresolved issue requires them locally. + +For e2e baselines, preserve known intentional analyzer limitations with reasons and inspect baseline changes. Do not treat adding a new baseline entry as a fix for a demonstrated source defect. New Hypervel regression fixtures should have exact expected results. + +## Consumer migration and final cleanup + +- Retain the deprecated validation contracts and audit their necessary compatibility usages as specified below. +- Convert declarations flagged by the default authoring rules in the components repo's source, skeletons, generator stubs, private packages, apps and documentation to the recommended forms, preserving runtime behavior and inherited precedence. Complete the chain/fake dispatch fixes before converting queue settings. In the components repo every flagged framework declaration is a compatibility exception, so only examples and generated application code convert: the queued-listener `$uniqueFor` example in `src/docs/events.md` (`#[UniqueFor(3600)]`), the nested set model examples, the skeleton `User` and Testbench's `user.stub`. Keep the rest with a specific reason each: models applications extend or replace (Pivot, DatabaseNotification, the Passport models, Sanctum's token, `Passkey`, Saloon's `OAuthAccount`) and open models (AI conversations, Telescope's `EntryModel`), whose attributes would override a subclass's `$incrementing = true`, `$timestamps = true`, `$keyType = 'int'` or `$primaryKey = 'id'`, merge with a subclass's list instead of being replaced, drop the table under a subclass's own `#[Table]`, or replace a subclass constructor's `$guarded = ['*']` set before `parent::__construct()`; and public queue properties whose removal leaves assignments without storage (`CallQueuedClosure::$deleteWhenMissingModels`, Scout's `$failOnTimeout` and `$uniqueFor`, `BroadcastAgent::$tries`). Keep compatibility type/rule fixtures where they test supported APIs; use a reasoned local ignore only for a necessary compatibility case, never a baseline or blanket policy disablement. +- After this branch is merged, audit `packages/hypervel`, `packages/hypervel-dev` and affected `apps/` under `/home/binaryfire/workspace/monorepo`; the merge itself needs owner authorization. Both private package configs currently override unmatched-ignore reporting to false. Add the analyzer as a dev dependency with Composer, update source/type/generated analysis configs where applicable, supply package Testbench configuration, and remove obsolete direct-extension includes/scans. Narrow `packages/hypervel/tests/Workflow/WorkflowPartitionTest.php`'s `incrementQuietlyForTest()`/`incrementEachQuietlyForTest()` returns to `int`, matching `Model`'s quiet operations. +- Private Composer repositories currently point at the main components repo's `src/*`. Before the merge, validate consumers against this worktree through a task-local Composer setup with its own installed dependencies (a scratch copy of the consumer, not a worktree, refreshed from the in-place edits before each run), without permanently committing a worktree-specific path or disrupting another workload's vendor directory. Final private manifests reference the intended durable package source/version. +- Audit every suppression affected by improved inference or the new ignore policy in the components repo, both private packages, skeletons and affected apps. Convert every reason-less identifier ignore and every blanket line/next-line ignore in their analyzed paths to a source fix or an accurate identifier-specific ignore with a parenthesized reason. Do not limit this to newly ported files or errors that became unmatched. Remove obsolete inline ignores/global patterns and correct stale explanations such as claiming a boot-registered macro is unknowable. PHPStan never reports an unmatched ignore inside a trait analyzed through a class in another file (`AnalyserResultFinalizer` skips it), so check trait ignores separately: neutralize them in a scratch copy without shifting lines and compare the full run's reports at their lines. Ignores in traits no analyzed class uses stay when they record a deliberate exception. This includes `packages/hypervel`'s two `preg_match` capture ignores, which 2.3.5 makes unmatched (`_tmp/pr-ideas/phpstan-preg-match-native-capture-types.md`; delete that handoff afterwards). Retain narrowly justified limitations; new broad ignores require the normal owner approval. +- Audit deprecated-API usage in those same consumers, including calls to PHP, Symfony, Carbon and other vendor APIs, not just Hypervel declarations. Use the supported replacement when it preserves the contract. Keep a reasoned identifier-specific ignore when deprecated usage remains necessary, including compatibility with a still-supported public API. Do not remove supported APIs, strip truthful deprecation tags, mark unrelated code deprecated to silence errors, or add runtime workarounds merely to satisfy analysis. Dependency defects follow the normal owner decision workflow. +- Add `src/docs/static-analysis.md`, titled Static Analysis, as the canonical page, with navigation links, following existing Laravel-style documentation. Cover installation, app/Testbench boot (package analysis types auth users from the configured Testbench app, whose users model `AUTH_MODEL` or the workbench `User` selects), supported versions, configuration/defaults, optional integrations, custom PHPDoc types, diagnostics, suppressions, safe fixes, cache behavior and the distinction between authoring recommendations and runtime API removals. + - Authoring guidance: a small overview table links to short topic sections; each topic gives the intended pattern with a compact example and its legitimate exception. Keep detailed examples out of wide tables and do not copy the configuration catalog. Fix first; ignore only a legitimate exception or analyzer limitation with `@phpstan-ignore identifier (reason)`, never a baseline for a real defect. A fact inferred from one boot does not prove a branch dead in every consumer. Fix an over-specific inferred type at its source, which for the analyzer's own inference is the analyzer; a truthful local PHPDoc is a last resort for a justified local exception, such as framework code reading a framework config default that applications replace. Use typed getters for single-type config values and `get()` only for intentionally nullable, union or mixed ones. Each new diagnostic's guidance lands with its implementation slice. + - Boundaries: framework-aware analysis requires `hypervel/phpstan` (plain PHPStan cannot resolve the framework's analyzer-only source refinements and falls back to the native declarations); config types come from static expressions, never `.env` values; container resolution, auth guards and user models, console commands, views, translations and the default locale come from the booted application, whose environment can select drivers and models. Package mode sees the Testbench application's registrations, not every consuming application. State the inference limits: unexecuted config writes through unrecognized flows, and the env rule's named-call matching. State in the page and release notes that adopting the documented setup diagnoses existing reason-less/blanket ignores and uses the included deprecation extension for known deprecated APIs across dependencies, including PHP, Symfony and Carbon. Explain its standard loading and opt-out mechanisms and upstream deprecated-scope behavior; do not promise detection of every runtime deprecation regardless of available metadata. Keep the package README minimal. +- Add a concise `src/docs/porting-from-laravel.md` entry: replace `larastan/larastan` with `hypervel/phpstan`, update the extension include, migrate Larastan-owned ignore identifiers (`larastan.*`, `rules.modelAppends`) to the documented `hypervel.*` mapping, and retain PHPStan core identifiers. Explain that retained parameter names remain compatible while defaults and omitted Octane/deferred-provider settings differ; link to the Static Analysis page. +- Update affected feature docs, skeleton setup, testing/type-fixture instructions, component manifests and package READMEs for centralized extension ownership. Amend `docs/todo.md` by removing completed items and the superseded instruction to keep extensions distributed. Preserve still-unimplemented runtime-removal and unrelated todos; update their references where this package now supplies checks. Never rewrite historical plans. +- Record intentionally omitted upstream features in the package README, a concise source comment at the natural registration/insertion point and `REMOVED:` comments at matching test locations, as required by the porting policy. Do not create empty placeholder classes for omitted features or annotate routine namespace changes and bug fixes as public API differences. +- Search the affected source, tests, types, active docs, manifests and scripts for obsolete namespaces, old extension paths, duplicated services, unused helper classes/settings, inaccurate compatibility claims and stale comments. Remove them together with their consumers. Keep upstream references/license attribution and historical plans intact. +- Before completion, review the final diff for source contract correctness, Laravel-style naming/ergonomics, coroutine ownership, analysis-only versus runtime cost, dependency isolation and unnecessary machinery. No custom service-container interpreter, universal lifetime annotation system, private PHPStan API patch or speculative registry should be needed. + +### Deprecated validation contracts + +`Hypervel\Contracts\Validation\Rule` and `ImplicitRule` are deprecated, but remain part of the supported validation implementation and public contract, matching current Laravel. Built-in `Email`, `Enum`, `Can`, `Password`, `File` and `AnyOf` expose `passes()`/`message()` through these interfaces, and `ClosureValidationRule` and `InvokableValidationRule` adapt the other rule forms into that execution path. + +The owner decided to retain these contracts and built-in implementations, including their deprecation tags and user-facing diagnostics: upstream still uses them, and removing them would diverge unnecessarily. This is not deferred removal work; do not add a removal todo. Removing just `implements Rule` while keeping the methods would still break interface/type compatibility. Do not wrap or rewrite the validation engine to hide deprecation reports. + +The framework's necessary internal uses carry the emitted identifier (`class.implementsDeprecatedInterface`, `parameter.deprecatedInterface`, `method.deprecatedInterface`) as a narrow inline ignore with one reason: "Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported." Avoid a global ignore. Existing applications/private rules should use `ValidationRule` where their public contract permits migration. User code implementing the deprecated interfaces continues to receive diagnostics; do not exclude these interfaces in the analyzer. Deprecation alone is not a runtime defect. + +## Completion criteria + +The central split package works in an application and a standalone Testbench-backed package, owns every reusable existing extension, retains the applicable Larastan engine/configuration/tests, provides the agreed Hypervel checks, and passes the verification matrix. Source contracts and supported APIs remain correct; all real findings introduced or exposed by the integration are fixed or taken through the explicit owner decision process. Old extension files, registrations, dead code and stale active documentation are gone. The plan's implementer obtains peer code-review signoff, summarizes the result and remaining owner-authorized external steps, and sends the user notification only after the terminal summary. After the entire plan is implemented, show the owner the AGENTS.md changes in chat as BEFORE and AFTER, compared through Git against the pre-audit commit `113c4c4dc` (no duplicate instruction files), so the owner can request further changes. diff --git a/docs/todo.md b/docs/todo.md index 03bbc57eba..d77e824763 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -29,10 +29,8 @@ - Find a clean, simple framework-wide solution for configuration-dependent services resolved before worker configuration reload. `server:reload` refreshes the existing configuration repository, but objects that have already copied configuration into their own state remain stale. For example, `SentryServiceProvider` eagerly resolves a worker-lifetime Hub and client during boot, so DSN, environment, and sampling changes are not applied until a full restart; resolving `Cache::store('some-store')` from a service provider populates `CacheManager`'s store cache before reload, so changes to that store's driver, connection, prefix, or other captured configuration are likewise not applied. Define the reload contract, audit framework-owned eager resolutions and manager caches, and solve the lifecycle at their shared owning boundary instead of adding package-specific refresh hooks or application workarounds. - Investigate where requiring and directly using a PHP extension would make framework code significantly faster than its current pure-PHP implementation. The framework already declares bundled extensions it depends on, so the question is which hot paths are doing in PHP what a C extension does natively. The worked example is `ext-gmp` for identifier encoding: UUID and ULID string conversion and any base32/base58/base62 short-id work exceed 64 bits, so `ramsey/uuid` and `symfony/uid` convert them digit by digit in PHP, while `gmp_init()`/`gmp_strval()` do arbitrary-base conversion natively — a hand-rolled base-36 UUID conversion measured 14.6 µs against 0.5 µs for the GMP equivalent with byte-identical output. Anything that fits in a 64-bit int (snowflakes, timestamps, counters) needs no extension, and hashing, encryption, and signatures are already C. Measure `Str::uuid()`/`Str::ulid()` and the other candidates before adding a requirement, and weigh each new extension against installation cost. - Convert untyped `$config->get()` calls across `src/` to the typed getters (`string()`, `integer()`, `float()`, `boolean()`, `array()`) without call-site defaults, for every key that isn't genuinely nullable. Defaults live in the merged config files — declare any key currently defaulted only at a call site in its package's config file as part of the conversion. Typed getters throw `InvalidArgumentException` naming the key on misconfiguration instead of letting a wrong type propagate silently, and give phpstan real return types. Bootstrap code that runs before config merging keeps its call-site defaults. Approved modernization per the Porting Packages policy in `AGENTS.md`; new code already follows the rule. -- Audit unmatched PHPStan inline ignores and global patterns with `reportUnmatchedIgnoredErrors` enabled — currently 196 unmatched inline ignores across 99 files plus 5 unmatched global patterns. Remove only suppressions that no longer match after tracing the underlying code; do not replace correct source with runtime branches or wider types merely to keep static analysis green. Decide as part of the work whether `phpstan.neon.dist` should then set `reportUnmatchedIgnoredErrors: true` permanently, since leaving it `false` lets the suppressions rot again. -- Create a `hypervel/phpstan` package of PHPStan rules for Hypervel's coroutine and worker-lifetime patterns, so mistakes no longer depend on agent steering and review alone. The rules check how code is written rather than one package's API, so the framework, applications, private packages and third-party packages must all be able to use them. Make it a development-only split package modeled on Larastan, with its own split repository and Packagist entry: require the supported `phpstan/phpstan` range and register the rules in an `extension.neon` listed under `extra.phpstan.includes`, so `phpstan/extension-installer` loads them after `composer require --dev hypervel/phpstan`. Include it from Components' own `phpstan.neon.dist`. Keep package-owned type extensions, such as those in Database, Foundation, Head and Scout, with their packages: they describe those packages' APIs and must load whenever the package is installed, while these rules report errors in the consumer's own code and must be opted into. Test each rule with PHPStan's `RuleTestCase` and fixtures covering both defects and valid code. - - Start with cancellation checks that miss cleanup exception chains around callbacks, listeners, framework I/O and `releaseIdleConnections()`, where `Coroutine::causedByCancellation()` is needed to prevent retries or continued work after cancellation. Detect a `catch (CanceledException)` that rethrows followed by a broader catch that reports, falls back, records an outcome or retries; `instanceof CanceledException` on a throwable other than one just caught as that type; `getPrevious() instanceof CanceledException`; and failure collectors such as `$failure ??= $exception` that should use `Coroutine::preferCancellation()`. Exempt Swoole and Hypervel native waits (channels, `WaitGroup`, `Coroutine::join()` and `Waiter`) only when they are the only work in the `try` block; other valid exact checks use `@phpstan-ignore` with a reason. Use the cancellation regression cases to assess detection and false positives, and preserve valid native-only checks, cleanup-error reporting and foreign-cancellation boundaries. Typed domain catches that can receive a wrapped cancellation, failure observers dispatched before a rethrow and ownership decisions remain review responsibilities rather than being inferred automatically. - - Then turn the coroutine-safety and worker-lifetime rules in `AGENTS.md` into checks. PHPStan rules for detection with type information belong in this package; consider Rector in dry-run mode or another linter only for checks PHPStan cannot express. Start with property writes outside the constructor on container-resolved classes (auto-singletons and singletons) that are not `Transient`, scoped, or execution-scoped, since per-request data stored there leaks to concurrent requests in the same worker. Intentional worker-lifetime state such as manager driver caches, registries, and resolved configuration needs an explicit way to opt out, so measure false positives across `src/` before choosing that mechanism. Then evaluate other patterns: static property writes outside boot code and `flushState()`; static caches keyed by request-derived values; request state captured in constructors of classes that are not execution-scoped; `Config::set()` during request handling; child coroutines started inside `DB::transaction()`, which borrow their own pooled connection and therefore run outside the transaction; and raw `sleep()`/`usleep()` in source instead of `Sleep`. +- Add nullable typed configuration getters, such as `nullableString()`, `nullableInteger()`, `nullableFloat()`, `nullableBoolean()` and `nullableArray()`, on the config repository and its contract, with matching Config facade metadata and `hypervel/phpstan` support (the configuration lookups and `ConfigTypeRule`). Accept the corresponding type or null, return null for missing keys without a default, and reject other types with the same key-specific exceptions as the existing strict getters. Preserve empty strings, empty arrays, false and zero rather than converting them to null. Keep `get()`/`config()` for mixed or other union values; prefer explicit nullable getters over a fluent `nullable()` proxy. Cover missing keys, explicit null, valid values, defaults and invalid types, and document when to choose strict, nullable or untyped access. +- Complete native parameter and return types in the global helpers in `src/support/src/helpers.php`, `src/collections/src/helpers.php` and `src/foundation/src/helpers.php`. Derive types from supported runtime behavior and callers, retaining PHPDoc generics and conditional types. Use `mixed` where necessary; preserve by-reference behavior and Laravel's accepted inputs unless a departure is approved. Verify with focused runtime tests and helper type fixtures. - Audit typed input accessors across `InteractsWithData`, Support `DataObject`, and Hypervel Data. Define the accepted integer, float, and boolean forms for each public contract. Extract a neutral Support conversion primitive only if `InteractsWithData` and `DataObject` converge on exactly the same strict semantics, and decide separately whether Hypervel Data should adopt those semantics rather than changing its tested permissive casts as a side effect. ## Legacy removals @@ -78,9 +76,10 @@ Remove older ways of doing things once a modern equivalent fully replaces them, - **Simplify the other initializers.** Once `$guarded` is gone, `guardConfiguration()` no longer needs to stop at a declaring class or read the property's default, and `classPropertyDeclarer()` is left with only the new boot check. Remove the `get_class_vars($class)['timestamps']` read in `isIgnoringTouch()`. Keep value checks such as `$this->primaryKey === 'id'` and `$this->timestamps === true`, because they protect values assigned before initialization. The merging in `initializeGuardsAttributes()` and `initializeHidesAttributes()` can stay. - **Nameless `Table` attributes.** The nearest `Table` attribute replaces a parent's entirely, because `resolveClassAttribute()` returns only one. So `#[Table(timestamps: false)]` on a child of `#[Table('flights')]` uses the name derived from the child's class, not `flights`. Keep this and document it in `src/docs/eloquent.md`: repeat the name, or use a dedicated attribute such as `WithoutTimestamps`, `WithoutIncrementing` or `DateFormat`, which leaves the parent's `Table` in place. Merging `Table` fields across classes would be a new rule. - **Scopes.** Delete `isLegacyScopeMethod()`, `$legacyScopeMethods` and its reset in `flushScopeCaches()`, simplify `hasNamedScope()` and `callNamedScope()`, and remove the legacy branch from `ModelScopeMethodResolver`. Convert `DatabaseNotification`, `HasRoles`, `HasPermissions` and Telescope's `EntryModel`, plus the `NamedScopes`, `CustomBuilderForwarding` and `SoftDeletes` type fixtures and the test fixtures. Other `scope[A-Z]` methods in `src/` are not model scopes and stay, including `Route::scopeBindings()`, `Passport::scopeIds()`, `StoredValueCache::scopeTokenKey()`, `ResolvesInheritedScopes::scopeExistsIn()` and the nested-set relation helpers. - - **Consumers.** Convert the framework's models (about 20 files), `skeletons/hypervel/app/Models/User.php`, `src/testbench/src/Foundation/Console/stubs/user.stub` and all test fixtures. This is the largest part of the work: about 230 test files declare `$table` alone. + - **Consumers.** Convert the framework's models that keep `hypervel.legacyModelProperty` and `hypervel.legacyModelScope` ignores (about 20 files), removing the ignores, and all test fixtures. This is the largest part of the work: about 230 test files declare `$table` alone. - **Documentation and rules.** - - Update `src/docs/eloquent.md`, `src/docs/nested-set.md` and `src/docs/migrations.md`. + - Update `src/docs/eloquent.md` and `src/docs/migrations.md`. + - `hypervel/phpstan`'s `NoLegacyModelPropertiesRule` and `NoLegacyModelScopesRule` then report declarations the runtime rejects. Report them whatever the equivalent attribute's value, drop their `noLegacyModelProperties`/`noLegacyModelScopes` switches and the `phpstan.types.neon.dist` overrides, and update the [Model Configuration](../src/docs/static-analysis.md#model-configuration) and [Model Scopes and Accessors](../src/docs/static-analysis.md#model-scopes-and-accessors) sections. - Rewrite the model part of "Inherited Properties" in `src/docs/porting-from-laravel.md`, including its "Common model properties to audit" list, and add the scope change. - Add a `Differences From Laravel` entry to `src/database/README.md`. - With owner approval, update `AGENTS.md`: the model examples under "Stricter typing", the `$table` note under "Helper class namespacing", the quick checklist's model-property step and "Use attributed local scopes". @@ -89,7 +88,6 @@ Remove older ways of doing things once a modern equivalent fully replaces them, ## Testing -- Remove the PHPStan `2.3.1` pins in the root, Database and Foundation manifests once a release fixes the 2.3.2 loop-narrowing regression: `DataCollectionEloquentCast::set()` rejects a value narrowed by `instanceof` against `class-string`. Verify the full source and type-fixture analyses before restoring the version ranges. - Adopt a Swoole release fixing the intermittent AIO shutdown hang in [swoole-src#6297](https://github.com/swoole/swoole-src/issues/6297). On 6.3.0RC1, ordinary coroutine scheduler teardown can wait forever for an idle native thread that missed its shutdown notification, including between tests. Verify the released fix with the standalone reproduction and affected suites; do not add a Hypervel workaround or a timing-dependent regression test. - Add the required title docblocks to the untitled non-test methods in `tests/`: about 7,600 methods in 1,500 files, mostly helpers, fixtures, and methods of anonymous classes in older tests. `AGENTS.md` requires a title on every method except test methods, but most existing tests omit them, and agents follow the surrounding code over the written rule, so new tests keep reproducing the gap until review catches it. Make the change as one approved sweep (short imperative titles only), then add an automated check, such as a PHPStan rule, so the code and the rule cannot drift apart again. Decide whether the check also covers `src/`. - Add native return types to the `test*` methods that lack them: about 5,600 methods in 490 files. `AGENTS.md` requires `: void` on test methods; a test that returns a value for `#[Depends]` takes its actual return type instead. As with method titles, untyped methods in existing files lead new tests to copy them, so make the change as one approved sweep and enforce it with the same automated check. @@ -139,7 +137,7 @@ Remove older ways of doing things once a modern equivalent fully replaces them, ## Validation -- Remove pipe-separated validation rule strings in favor of arrays of rules across both `hypervel/validation` and `hypervel/data`, including Data's rule normalizer, denormalizer and `#[Rule]` handling. Update callers, tests and documentation together; preserve pipes inside individual regex patterns. +- Remove pipe-separated validation rule strings in favor of arrays of rules across both `hypervel/validation` and `hypervel/data`, including Data's rule normalizer, denormalizer and `#[Rule]` handling. Update callers, tests and documentation together; preserve pipes inside individual regex patterns. `hypervel/phpstan`'s pipe-separated validation rules then report strings the runtime rejects: drop their `noPipeSeparatedValidationRules` switch and its `phpstan.types.neon.dist` override, and update the [Validation Rules](../src/docs/static-analysis.md#validation-rules) section. - Adopt Brick Math 0.20's bounded parsing API for untrusted numeric validation inputs once the supported dependency graph permits it. Ramsey UUID currently limits Brick Math to 0.18, which has neither bounded parsing nor 0.20's parser-backtracking fix, so an intermediate upgrade would not address this issue. Preserve the configurable exponent-range behavior and cover delegated and compiled rules, oversized mantissas and exponents, malformed input, and `multiple_of`; do not add a local parsing workaround or disguise an incompatible Brick version with a Composer alias. ## Sentry diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 99d2d1bf85..5f47a78690 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,10 +4,8 @@ # limitations. includes: - - src/database/extension.neon - - src/foundation/extension.neon - - src/head/extension.neon - - src/scout/extension.neon + - src/phpstan/extension.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon parameters: tmpDir: .cache/phpstan @@ -18,7 +16,6 @@ parameters: minimumNumberOfJobsPerProcess: 2 inferPrivatePropertyTypeFromConstructor: true treatPhpDocTypesAsCertain: false - reportUnmatchedIgnoredErrors: false paths: - bin/sync-skeleton-config - src @@ -44,6 +41,12 @@ parameters: identifier: function.alreadyNarrowedType path: src/testbench/src/Concerns/CreatesApplication.php + # InteractsWithTestCase is imported through several Testbench traits, so PHPStan reports this + # WithFixtures call once per import, and an inline ignore covers only one report. + - message: '#^Call to an undefined static method .+::setupWithFixturesForTestingEnvironment\(\)\.$#' + identifier: staticMethod.notFound + path: src/testbench/src/Concerns/InteractsWithTestCase.php + # protoc 35.1 emits a two-argument enum check, while google/protobuf 5.35.1 # declares the userland method with one argument and relies on PHP ignoring the extra value. - identifier: arguments.count @@ -61,20 +64,12 @@ parameters: - '#Access to an undefined property Hypervel\\Database\\Schema\\ForeignIdColumnDefinition::\$#' - '#Call to an undefined method Hypervel\\Database\\Schema\\ForeignIdColumnDefinition::#' - # HasNode methods - mixed in at app level, not on base Model class - - message: '#Call to an undefined method Hypervel\\Database\\Eloquent\\Model::new(ScopedQuery|NestedSetQuery)\(\)#' - path: src/nested-set/* - # SoftDeletes trait methods - optionally mixed in, can't be statically verified - '#Call to an undefined method .*::(getDeletedAtColumn|getQualifiedDeletedAtColumn|withTrashed|withoutTrashed|onlyTrashed|forceDelete|restore|trashed|isForceDeleting)\(\)#' - # Generic template type limitations - PHPStan can't resolve methods through generic TModel/TRelatedModel - - '#Call to an undefined method TModel of Hypervel\\Database\\Eloquent\\Model::#' - - '#Call to an undefined method TRelatedModel of Hypervel\\Database\\Eloquent\\Model::#' - - # Covariant template types in invariant/contravariant positions - Laravel uses @template-covariant - # for semantic documentation on collection-like classes even though it violates strict variance rules. - # The code is functionally correct; this is a PHPStan limitation with PHP's lack of runtime generics. + # Collections, paginators and InvokableComponentVariable keep Laravel's covariant TValue, so a + # narrower instance can be passed where a wider one is expected. Writes through the wider reference + # are checked against the wider type, so they can break the original reference's narrower contract. - identifier: generics.variance # Eloquent Relation subclass method overrides - covariance/contravariance issues with complex generics @@ -89,9 +84,5 @@ parameters: - message: '#object\{pivot:#' path: src/database/* - # call_user_func with void callbacks - intentional pattern in Eloquent - - '#Result of function call_user_func \(void\) is used\.#' - - '#Result of callable passed to call_user_func\(\) \(void\) is used\.#' - # Laravel's fluent factory pattern (Collection::make, Model::create, etc.) relies on new static() throughout - '#Unsafe usage of new static#' diff --git a/phpstan.types.neon.dist b/phpstan.types.neon.dist index 8f75c3d250..455a70eed1 100644 --- a/phpstan.types.neon.dist +++ b/phpstan.types.neon.dist @@ -1,16 +1,19 @@ includes: - - src/database/extension.neon - - src/foundation/extension.neon - - src/head/extension.neon - - src/scout/extension.neon + - src/phpstan/extension.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon parameters: level: max tmpDir: .cache/phpstan-types scanDirectories: - - src/database/src/PHPStan - - src/foundation/src/PHPStan - - src/head/src/PHPStan - - src/scout/src/PHPStan + - src/phpstan/src paths: - types + # These fixtures assert the types of supported APIs, not authoring policy. + noLegacyModelScopes: false + noLegacyModelProperties: false + noLegacyQueueProperties: false + noPipeSeparatedValidationRules: false + checkModelMethodVisibility: false + noUnnecessaryEnumerableToArrayCalls: false + noEnvCallsOutsideOfConfig: false diff --git a/phpunit.phpstan.xml.dist b/phpunit.phpstan.xml.dist new file mode 100644 index 0000000000..ac8cee3587 --- /dev/null +++ b/phpunit.phpstan.xml.dist @@ -0,0 +1,45 @@ + + + + + ./tests/PHPStan + + + + + + + + + + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 758d559522..facf0d6b18 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,6 +17,8 @@ ./tests + + ./tests/PHPStan diff --git a/skeletons/hypervel/app/Models/User.php b/skeletons/hypervel/app/Models/User.php index 32f59eb27b..67cfee979c 100644 --- a/skeletons/hypervel/app/Models/User.php +++ b/skeletons/hypervel/app/Models/User.php @@ -5,10 +5,14 @@ namespace App\Models; use Database\Factories\UserFactory; +use Hypervel\Database\Eloquent\Attributes\Fillable; +use Hypervel\Database\Eloquent\Attributes\Hidden; use Hypervel\Database\Eloquent\Factories\HasFactory; use Hypervel\Foundation\Auth\User as Authenticatable; use Hypervel\Notifications\Notifiable; +#[Fillable(['name', 'email', 'password'])] +#[Hidden(['password', 'remember_token'])] class User extends Authenticatable { /** @use HasFactory */ @@ -16,23 +20,6 @@ class User extends Authenticatable use Notifiable; - /** - * The attributes that are mass assignable. - */ - protected array $fillable = [ - 'name', - 'email', - 'password', - ]; - - /** - * The attributes that should be hidden for serialization. - */ - protected array $hidden = [ - 'password', - 'remember_token', - ]; - /** * Get the attributes that should be cast. * diff --git a/skeletons/hypervel/composer.json b/skeletons/hypervel/composer.json index de862e8793..d650f9ec9d 100644 --- a/skeletons/hypervel/composer.json +++ b/skeletons/hypervel/composer.json @@ -19,11 +19,12 @@ "fakerphp/faker": "^1.24", "friendsofphp/php-cs-fixer": "^3.57.2", "hypervel/doctor": "^0.4", + "hypervel/phpstan": "^0.4", "hypervel/watcher": "^0.4", "mockery/mockery": "^1.6.15", "nunomaduro/collision": "^8.9", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.3.5", "phpunit/phpunit": "~13.4.3", "swoole/ide-helper": "^6.0.2" }, diff --git a/skeletons/hypervel/phpstan.neon b/skeletons/hypervel/phpstan.neon index 91ebdbbacb..7ccb5f7405 100644 --- a/skeletons/hypervel/phpstan.neon +++ b/skeletons/hypervel/phpstan.neon @@ -1,8 +1,5 @@ parameters: level: 5 - treatPhpDocTypesAsCertain: false paths: - app - config - excludePaths: - ignoreErrors: diff --git a/src/ai/composer.json b/src/ai/composer.json index 496bc11fcb..7c3d24915d 100644 --- a/src/ai/composer.json +++ b/src/ai/composer.json @@ -61,7 +61,7 @@ "psr/http-message": "^2.0", "symfony/console": "^8.1.10", "symfony/finder": "^8.1", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/yaml": "^8.1.11" }, "conflict": { diff --git a/src/ai/src/Files/Concerns/HasStoredContent.php b/src/ai/src/Files/Concerns/HasStoredContent.php index 9a2b09e824..95297a3bd1 100644 --- a/src/ai/src/Files/Concerns/HasStoredContent.php +++ b/src/ai/src/Files/Concerns/HasStoredContent.php @@ -35,8 +35,7 @@ public function mimeType(): ?string { $disk = Storage::disk($this->disk); - // The contract omits adapter metadata methods, which every shipped disk provides. - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (the contract omits adapter metadata methods, which every shipped disk provides) return $this->mime ?? ($disk->mimeType($this->path) ?: null); } diff --git a/src/ai/src/Gateway/Bedrock/BedrockException.php b/src/ai/src/Gateway/Bedrock/BedrockException.php index 1254688fc7..735dc156db 100644 --- a/src/ai/src/Gateway/Bedrock/BedrockException.php +++ b/src/ai/src/Gateway/Bedrock/BedrockException.php @@ -12,6 +12,7 @@ use Hypervel\Ai\Exceptions\ProviderConnectionException; use Hypervel\Ai\Exceptions\ProviderOverloadedException; use Hypervel\Ai\Exceptions\RateLimitedException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Str; use Swoole\Coroutine\CanceledException; use Throwable; @@ -39,7 +40,7 @@ class BedrockException */ public static function toAiException(Throwable $e, string $provider, string $model): AiException { - if ($e instanceof CanceledException) { + if (Coroutine::causedByCancellation($e)) { throw $e; } diff --git a/src/ai/src/Gateway/Bedrock/BedrockHttpHandler.php b/src/ai/src/Gateway/Bedrock/BedrockHttpHandler.php index 1316867354..e657e36999 100644 --- a/src/ai/src/Gateway/Bedrock/BedrockHttpHandler.php +++ b/src/ai/src/Gateway/Bedrock/BedrockHttpHandler.php @@ -13,6 +13,7 @@ use GuzzleHttp\Promise\RejectedPromise; use GuzzleHttp\TransferStats; use Hypervel\Ai\AiManager; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Client\ConnectionException; use Hypervel\Http\Client\Factory; use Hypervel\Http\Client\RequestException as HttpRequestException; @@ -78,9 +79,11 @@ public function __invoke(RequestInterface $request, array $options = []): Promis } return new FulfilledPromise($response); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $response = $exception instanceof HttpRequestException ? $exception->response->toPsrResponse() : null; if ($exception instanceof ConnectionException && $exception->getPrevious() !== null) { diff --git a/src/ai/src/Jobs/BroadcastAgent.php b/src/ai/src/Jobs/BroadcastAgent.php index 0f58f95e7b..18aaaa933b 100644 --- a/src/ai/src/Jobs/BroadcastAgent.php +++ b/src/ai/src/Jobs/BroadcastAgent.php @@ -27,6 +27,7 @@ class BroadcastAgent implements ShouldQueue } use Queueable; + // @phpstan-ignore hypervel.legacyQueueProperty (public property; removing it would leave assignments to it without declared storage) public int $tries = 1; public string $invocationId; diff --git a/src/ai/src/Models/Conversation.php b/src/ai/src/Models/Conversation.php index f61ea7849a..d72d48416f 100644 --- a/src/ai/src/Models/Conversation.php +++ b/src/ai/src/Models/Conversation.php @@ -24,6 +24,7 @@ class Conversation extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** diff --git a/src/ai/src/Models/ConversationMessage.php b/src/ai/src/Models/ConversationMessage.php index bb0ad4309b..a03965e450 100644 --- a/src/ai/src/Models/ConversationMessage.php +++ b/src/ai/src/Models/ConversationMessage.php @@ -37,6 +37,7 @@ class ConversationMessage extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** @@ -44,6 +45,7 @@ class ConversationMessage extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Appends] would merge with a subclass's $appends instead of letting it replace this list) protected array $appends = ['tool_calls', 'tool_results', 'provider_tool_calls']; /** @@ -62,6 +64,8 @@ class ConversationMessage extends Model /** * The tool calls made across every step of the turn, in step order. + * + * @return Attribute */ protected function toolCalls(): Attribute { @@ -70,6 +74,8 @@ protected function toolCalls(): Attribute /** * The provider-hosted tool calls made across every step of the turn, in step order. + * + * @return Attribute */ protected function providerToolCalls(): Attribute { @@ -78,6 +84,8 @@ protected function providerToolCalls(): Attribute /** * The tool results recorded across every step of the turn, in step order. + * + * @return Attribute, never> */ protected function toolResults(): Attribute { diff --git a/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php b/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php index 6f84e91072..93649be106 100644 --- a/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php +++ b/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php @@ -27,8 +27,8 @@ use Hypervel\Ai\Streaming\Events\ToolApprovalRequest; use Hypervel\Ai\Streaming\Events\ToolCall; use Hypervel\Ai\Streaming\Events\ToolResult; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Arr; -use Swoole\Coroutine\CanceledException; use Throwable; use function Hypervel\Ai\ulid; @@ -135,7 +135,7 @@ protected function parts(StreamableAgentResponse $response): Generator } } catch (Throwable $exception) { // Masked error parts are rendered after finally releases the response. - if (! $this->started && ! $exception instanceof CanceledException) { + if (! $this->started && ! Coroutine::causedByCancellation($exception)) { $this->threadId ??= $response->conversationId; } diff --git a/src/ai/src/Tools/Filesystem/GetFileMetadata.php b/src/ai/src/Tools/Filesystem/GetFileMetadata.php index a2ff2c4416..5db4d9f619 100644 --- a/src/ai/src/Tools/Filesystem/GetFileMetadata.php +++ b/src/ai/src/Tools/Filesystem/GetFileMetadata.php @@ -52,8 +52,7 @@ public function handle(Request $request): string 'path' => $path, 'size' => $size, 'last_modified' => rescue(fn (): int => $disk->lastModified($path), report: false), - // The contract omits adapter metadata methods, which every shipped disk provides. - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (the contract omits adapter metadata methods, which every shipped disk provides) 'mime_type' => rescue(fn (): false|string => $disk->mimeType($path), report: false), 'visibility' => rescue(fn (): string => $disk->getVisibility($path), report: false), ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/src/ai/src/Tools/Filesystem/GetFileUrl.php b/src/ai/src/Tools/Filesystem/GetFileUrl.php index ac19933bc2..bfd167d6d2 100644 --- a/src/ai/src/Tools/Filesystem/GetFileUrl.php +++ b/src/ai/src/Tools/Filesystem/GetFileUrl.php @@ -37,11 +37,10 @@ public function handle(Request $request): string return "File [{$path}] does not exist."; } - // URL methods are disk capabilities outside the base filesystem contract. return $minutes > 0 - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (disk capability outside the Filesystem contract) ? $disk->temporaryUrl($path, now()->addMinutes($minutes)) - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (disk capability outside the Filesystem contract) : $disk->url($path); } catch (Throwable $throwable) { if (Coroutine::causedByCancellation($throwable)) { diff --git a/src/auth/README.md b/src/auth/README.md index 89418ae842..b2ae2ed95e 100644 --- a/src/auth/README.md +++ b/src/auth/README.md @@ -13,5 +13,6 @@ Documentation: https://hypervel.org/docs/authentication - `guest:{guard}` selects the first named guard as the request's default guard on pass-through, mirroring how `auth:{guard}` selects on success. - Password confirmation is scoped to the current guard, and each guard may define its own `password_timeout`. - Remember cookies require a password HMAC; the legacy raw-password-hash fallback is intentionally omitted. +- `CacheTokenRepository` accepts any `Hypervel\Contracts\Cache\Repository`, because cache stores, including custom drivers, only promise that contract. Subclasses that redeclare its promoted `$cache` property must use the contract type. Ported from: https://github.com/laravel/framework diff --git a/src/auth/composer.json b/src/auth/composer.json index 822d360f4f..4e9aa7964e 100644 --- a/src/auth/composer.json +++ b/src/auth/composer.json @@ -43,8 +43,8 @@ "hypervel/support": "^0.4", "nesbot/carbon": "^3.13.1", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "minimum-stability": "dev", "autoload": { diff --git a/src/auth/src/Access/AuthorizationException.php b/src/auth/src/Access/AuthorizationException.php index c67faf7d96..c65108dfc7 100644 --- a/src/auth/src/Access/AuthorizationException.php +++ b/src/auth/src/Access/AuthorizationException.php @@ -39,6 +39,8 @@ public function response(): ?Response /** * Set the response from the gate. + * + * @return $this */ public function setResponse(?Response $response): static { @@ -49,6 +51,8 @@ public function setResponse(?Response $response): static /** * Set the HTTP response status code. + * + * @return $this */ public function withStatus(?int $status): static { @@ -59,6 +63,8 @@ public function withStatus(?int $status): static /** * Set the HTTP response status code to 404. + * + * @return $this */ public function asNotFound(): static { diff --git a/src/auth/src/Access/Gate.php b/src/auth/src/Access/Gate.php index e250bfb644..679c36e2cf 100644 --- a/src/auth/src/Access/Gate.php +++ b/src/auth/src/Access/Gate.php @@ -179,6 +179,8 @@ protected function authorizeOnDemand(mixed $condition, ?string $message, int|str * Boot-only. The definition persists on the singleton Gate for the worker * lifetime and affects authorization in every subsequent request. * + * @return $this + * * @throws InvalidArgumentException */ public function define(UnitEnum|string $ability, array|callable|string $callback): static @@ -207,6 +209,8 @@ public function define(UnitEnum|string $ability, array|callable|string $callback * * Boot-only. The definitions persist on the singleton Gate for the worker * lifetime and affect authorization in every subsequent request. + * + * @return $this */ public function resource(string $name, string $class, ?array $abilities = null): static { @@ -265,6 +269,8 @@ protected function buildAbilityCallback(string $ability, string $callback): Clos * * Boot-only. The policy persists on the singleton Gate for the worker * lifetime and affects authorization in every subsequent request. + * + * @return $this */ public function policy(string $class, string $policy): static { @@ -282,6 +288,8 @@ public function policy(string $class, string $policy): static * * Boot-only. The callback persists on the singleton Gate for the worker * lifetime and runs during authorization in every subsequent request. + * + * @return $this */ public function before(callable $callback): static { @@ -295,6 +303,8 @@ public function before(callable $callback): static * * Boot-only. The callback persists on the singleton Gate for the worker * lifetime and runs during authorization in every subsequent request. + * + * @return $this */ public function after(callable $callback): static { @@ -706,6 +716,8 @@ protected function guessPolicyName(string $class): array * * Boot-only. The callback persists on the singleton Gate for the worker * lifetime and affects policy discovery in every subsequent request. + * + * @return $this */ public function guessPolicyNamesUsing(callable $callback): static { @@ -860,6 +872,8 @@ public function policies(): array * * Boot-only. The response persists on the singleton Gate for the worker * lifetime and affects authorization failures in every subsequent request. + * + * @return $this */ public function defaultDenialResponse(Response $response): static { @@ -874,6 +888,8 @@ public function defaultDenialResponse(Response $response): static * Tests only. Swaps the container on the singleton Gate used by every * coroutine; per-request use races across coroutines and breaks every * concurrent authorization check. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/auth/src/Access/Response.php b/src/auth/src/Access/Response.php index 0524b348bd..fadd8d99d5 100644 --- a/src/auth/src/Access/Response.php +++ b/src/auth/src/Access/Response.php @@ -110,6 +110,8 @@ public function authorize(): static /** * Set the HTTP response status code. + * + * @return $this */ public function withStatus(?int $status): static { @@ -120,6 +122,8 @@ public function withStatus(?int $status): static /** * Set the HTTP response status code to 404. + * + * @return $this */ public function asNotFound(): static { diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index fd4f6dcd6c..b70379dd64 100755 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -219,10 +219,12 @@ public function setDefaultDriver(UnitEnum|string $name): void /** * Register a new callback based request guard. + * + * @return $this */ public function viaRequest(string $driver, callable $callback): static { - return $this->extend($driver, function ($app, $name, $config) use ($callback) { + return $this->extend($driver, function (Container $app, string $name, array $config) use ($callback): RequestGuard { return new RequestGuard($name, $callback, $app, $this->createUserProvider($config['provider'] ?? null)); }); } @@ -246,6 +248,8 @@ public function userResolver(): Closure * Set the callback to be used to resolve users. * * Uses coroutine Context so one request's override doesn't affect others. + * + * @return $this */ public function resolveUsersUsing(Closure $userResolver): static { @@ -292,6 +296,8 @@ public function clearUserCache(mixed $identifier, UnitEnum|string|null $guard = * Boot-only. The callback persists in authentication middleware and * exception static properties for the worker lifetime and affects every * subsequent unauthenticated or session-mismatch request. + * + * @return $this */ public function redirectGuestsTo(callable|string|null $redirect): static { @@ -306,6 +312,8 @@ public function redirectGuestsTo(callable|string|null $redirect): static * Boot-only. The callback persists in the guest middleware static property * for the worker lifetime and affects every subsequent already-authenticated * guest-route request. + * + * @return $this */ public function redirectUsersTo(callable|string $redirect): static { @@ -320,6 +328,8 @@ public function redirectUsersTo(callable|string $redirect): static * Boot-only. The callbacks persist in authentication middleware and * exception static properties for the worker lifetime and affect every * subsequent matching request. + * + * @return $this */ public function redirectTo(callable|string|null $guests = null, callable|string|null $users = null): static { @@ -359,6 +369,8 @@ public function extend(string $driver, Closure $callback): static * Boot-only. The callback persists in the singleton's customProviderCreators * array for the worker lifetime and applies to every subsequent user * provider resolution. + * + * @return $this */ public function provider(string $name, Closure $callback): static { @@ -382,6 +394,8 @@ public function hasResolvedGuards(): bool * coroutines may already hold references to the prior guard instances * (each holding its own coroutine-scoped state) that next resolution * will not share. + * + * @return $this */ public function forgetGuards(): static { @@ -427,6 +441,8 @@ public function getAuthContextKeys(): array * Tests only. Swaps the singleton's container reference; per-request use * races across coroutines and breaks every concurrent request's auth * resolution. + * + * @return $this */ public function setApplication(Container $app): static { diff --git a/src/auth/src/Console/ClearResetsCommand.php b/src/auth/src/Console/ClearResetsCommand.php index 8131d02c0e..d73a4e9409 100644 --- a/src/auth/src/Console/ClearResetsCommand.php +++ b/src/auth/src/Console/ClearResetsCommand.php @@ -4,6 +4,7 @@ namespace Hypervel\Auth\Console; +use Hypervel\Auth\Passwords\PasswordBroker; use Hypervel\Console\Command; use Symfony\Component\Console\Attribute\AsCommand; @@ -25,7 +26,10 @@ class ClearResetsCommand extends Command */ public function handle(): void { - $this->hypervel->make('auth.password')->broker($this->argument('name'))->getRepository()->deleteExpired(); + /** @var PasswordBroker $broker */ + $broker = $this->hypervel->make('auth.password')->broker($this->argument('name')); + + $broker->getRepository()->deleteExpired(); $this->components->info('Expired reset tokens cleared successfully.'); } diff --git a/src/auth/src/EloquentUserProvider.php b/src/auth/src/EloquentUserProvider.php index 926234d8ec..dedab97e9e 100755 --- a/src/auth/src/EloquentUserProvider.php +++ b/src/auth/src/EloquentUserProvider.php @@ -145,7 +145,7 @@ public function retrieveById(mixed $identifier): ?UserContract */ protected function fetchUserById(mixed $identifier): ?UserContract { - return $this->newUserByIdQuery($identifier)->first(); // @phpstan-ignore return.type + return $this->newUserByIdQuery($identifier)->first(); } /** @@ -153,11 +153,13 @@ protected function fetchUserById(mixed $identifier): ?UserContract */ protected function fetchUserByIdForCache(mixed $identifier): ?UserContract { - return $this->newUserByIdQuery($identifier)->useWritePdo()->first(); // @phpstan-ignore return.type + return $this->newUserByIdQuery($identifier)->useWritePdo()->first(); } /** * Get the query for retrieving a user by ID. + * + * @return Builder */ protected function newUserByIdQuery(mixed $identifier): Builder { @@ -242,7 +244,7 @@ public function retrieveByCredentials(#[SensitiveParameter] array $credentials): } } - return $query->first(); /* @phpstan-ignore return.type */ + return $query->first(); } /** @@ -296,6 +298,7 @@ public function rehashPasswordIfRequired(UserContract $user, #[SensitiveParamete * the provider used by every subsequent authentication lookup. * * @param null|array $tags optional tag names enabling tag-based bulk flush; requires any-mode tag support + * @return $this * * @throws InvalidArgumentException when the TTL, tags, or resolved store are not supported */ @@ -599,7 +602,7 @@ protected function registerCacheInvalidationEvents(): void * @template TModel of Model * * @param null|TModel $model - * @return Builder + * @return ($model is null ? Builder : Builder) */ protected function newModelQuery(?Model $model = null): Builder { @@ -637,6 +640,8 @@ public function getHasher(): HasherContract * * Boot or tests only. User providers are held by cached guards; runtime use * mutates password verification for every subsequent authentication lookup. + * + * @return $this */ public function setHasher(HasherContract $hasher): static { @@ -662,6 +667,7 @@ public function getModel(): string * changes the model used by every subsequent authentication lookup. * * @param class-string $model + * @return $this */ public function setModel(string $model): static { @@ -691,6 +697,7 @@ public function getQueryCallback(): ?Closure * mutates the query applied to every subsequent authentication lookup. * * @param null|(Closure(Builder<*>):mixed) $queryCallback + * @return $this */ public function withQuery(?Closure $queryCallback = null): static { diff --git a/src/auth/src/Events/Verified.php b/src/auth/src/Events/Verified.php index e7abe0ba62..37633c2e89 100644 --- a/src/auth/src/Events/Verified.php +++ b/src/auth/src/Events/Verified.php @@ -4,6 +4,7 @@ namespace Hypervel\Auth\Events; +use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Auth\MustVerifyEmail; use Hypervel\Queue\SerializesModels; @@ -14,10 +15,13 @@ class Verified /** * Create a new event instance. * - * @param MustVerifyEmail $user the verified user + * Laravel documents a MustVerifyEmail user, but explicit verification also + * dispatches this event for authenticated users that only use the trait. + * + * @param Authenticatable|MustVerifyEmail $user the verified user */ public function __construct( - public MustVerifyEmail $user, + public Authenticatable|MustVerifyEmail $user, ) { } } diff --git a/src/auth/src/Middleware/AuthenticateWithBasicAuth.php b/src/auth/src/Middleware/AuthenticateWithBasicAuth.php index 5104f00601..1fd28543ef 100644 --- a/src/auth/src/Middleware/AuthenticateWithBasicAuth.php +++ b/src/auth/src/Middleware/AuthenticateWithBasicAuth.php @@ -35,7 +35,7 @@ public static function using(?string $guard = null, ?string $field = null): stri */ public function handle(Request $request, Closure $next, ?string $guard = null, ?string $field = null): Response { - $this->auth->guard($guard)->basic($field ?: 'email'); /* @phpstan-ignore method.notFound (basic() is on SupportsBasicAuth, not the Guard contract) */ + $this->auth->guard($guard)->basic($field ?: 'email'); return $next($request); } diff --git a/src/auth/src/MustVerifyEmail.php b/src/auth/src/MustVerifyEmail.php index 7f76637813..5c47547ca0 100644 --- a/src/auth/src/MustVerifyEmail.php +++ b/src/auth/src/MustVerifyEmail.php @@ -4,13 +4,9 @@ namespace Hypervel\Auth; -use Carbon\CarbonInterface; use Hypervel\Auth\Notifications\VerifyEmail; /** - * @property string $email - * @property null|CarbonInterface $email_verified_at - * * @method void notify(mixed $instance) */ trait MustVerifyEmail diff --git a/src/auth/src/Passwords/CacheTokenRepository.php b/src/auth/src/Passwords/CacheTokenRepository.php index ad66c76521..e9c9357fd3 100644 --- a/src/auth/src/Passwords/CacheTokenRepository.php +++ b/src/auth/src/Passwords/CacheTokenRepository.php @@ -4,8 +4,8 @@ namespace Hypervel\Auth\Passwords; -use Hypervel\Cache\Repository; use Hypervel\Contracts\Auth\CanResetPassword as CanResetPasswordContract; +use Hypervel\Contracts\Cache\Repository; use Hypervel\Contracts\Hashing\Hasher as HasherContract; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Str; diff --git a/src/auth/src/Passwords/CanResetPassword.php b/src/auth/src/Passwords/CanResetPassword.php index 2397f9c1ea..9d10f8e998 100644 --- a/src/auth/src/Passwords/CanResetPassword.php +++ b/src/auth/src/Passwords/CanResetPassword.php @@ -8,8 +8,6 @@ use SensitiveParameter; /** - * @property string $email - * * @method void notify(mixed $instance) */ trait CanResetPassword diff --git a/src/auth/src/RequestGuard.php b/src/auth/src/RequestGuard.php index 61d83955f8..25e793966d 100644 --- a/src/auth/src/RequestGuard.php +++ b/src/auth/src/RequestGuard.php @@ -116,6 +116,8 @@ public function hasUser(): bool /** * Set the current user. + * + * @return $this */ public function setUser(AuthenticatableContract $user): static { @@ -126,6 +128,8 @@ public function setUser(AuthenticatableContract $user): static /** * Forget the current user. + * + * @return $this */ public function forgetUser(): static { diff --git a/src/auth/src/SessionGuard.php b/src/auth/src/SessionGuard.php index 4de41ca28d..7c6319e90d 100644 --- a/src/auth/src/SessionGuard.php +++ b/src/auth/src/SessionGuard.php @@ -816,6 +816,8 @@ protected function getRememberDuration(): int * * Boot-only. The duration is stored on the worker-lifetime guard and affects * every subsequent request. + * + * @return $this */ public function setRememberDuration(int $minutes): static { @@ -894,6 +896,8 @@ public function hasUser(): bool /** * Set the current user. + * + * @return $this */ public function setUser(AuthenticatableContract $user): static { @@ -908,6 +912,8 @@ public function setUser(AuthenticatableContract $user): static /** * Forget the current user. + * + * @return $this */ public function forgetUser(): static { diff --git a/src/auth/src/TokenGuard.php b/src/auth/src/TokenGuard.php index 9b84e3a947..cb6018b895 100644 --- a/src/auth/src/TokenGuard.php +++ b/src/auth/src/TokenGuard.php @@ -156,6 +156,8 @@ public function hasUser(): bool /** * Set the current user. + * + * @return $this */ public function setUser(AuthenticatableContract $user): static { @@ -166,6 +168,8 @@ public function setUser(AuthenticatableContract $user): static /** * Forget the current user. + * + * @return $this */ public function forgetUser(): static { diff --git a/src/broadcasting/composer.json b/src/broadcasting/composer.json index 21f39ac57f..9e0c4a70cc 100644 --- a/src/broadcasting/composer.json +++ b/src/broadcasting/composer.json @@ -43,13 +43,13 @@ "hypervel/support": "^0.4", "psr/http-message": "^2.0", "psr/log": "^3.0", - "symfony/http-kernel": "^8.1" + "symfony/http-kernel": "^8.1.11" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.1.10).", "hypervel/redis": "Required to use the Redis broadcast driver (^0.4).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^7.2).", - "symfony/http-client": "Required to use the Mercure broadcast driver (^8.1).", + "symfony/http-client": "Required to use the Mercure broadcast driver (^8.1.11).", "symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).", "web-token/jwt-library": "Required to use the Mercure broadcast driver (^4.2.3)." }, diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 780e1e164c..2e58f8f5ed 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -682,6 +682,8 @@ public function getApplication(): Container * Tests only. Swaps the singleton's container reference; per-request use * races across coroutines and breaks every concurrent request resolving * broadcasters through this manager. + * + * @return $this */ public function setApplication(Container $app): static { @@ -695,6 +697,8 @@ public function setApplication(Container $app): static * * Boot or tests only. This is cache-only: pooled broadcasters remain * shared resources until purged or reclaimed by their idle TTL. + * + * @return $this */ public function forgetDrivers(): static { diff --git a/src/broadcasting/src/Broadcasters/AblyBroadcaster.php b/src/broadcasting/src/Broadcasters/AblyBroadcaster.php index 62e70b3a38..c24f384d55 100644 --- a/src/broadcasting/src/Broadcasters/AblyBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/AblyBroadcaster.php @@ -130,7 +130,7 @@ public function broadcast(array $channels, string $event, array $payload = []): $message = $this->buildAblyMessage($event, $payload); ConnectionResolver::releaseIdleConnections(); - /* @phpstan-ignore arguments.count, argument.type (Ably declares two @method publish() overloads and PHPStan keeps only the last; publish(...$args) accepts a Message as its sole argument.) */ + /* @phpstan-ignore arguments.count (PHPStan keeps only the last of Ably's two @method publish() overloads; publish(...$args) accepts a sole Message), argument.type (the same overload limitation) */ $channel->publish($message); } finally { if ($channel->getCipherParams() === null) { diff --git a/src/broadcasting/src/Broadcasters/Broadcaster.php b/src/broadcasting/src/Broadcasters/Broadcaster.php index c25812c25b..5072dca7e3 100644 --- a/src/broadcasting/src/Broadcasters/Broadcaster.php +++ b/src/broadcasting/src/Broadcasters/Broadcaster.php @@ -93,6 +93,8 @@ public function resolveAuthenticatedUserUsing(?Closure $callback): void * Boot-only. The channel authorizer and options persist in shared static * state on the Broadcaster class for the worker lifetime and apply to * every subsequent connection authentication. + * + * @return $this */ public function channel(HasBroadcastChannel|string $channel, callable|string $callback, array $options = []): static { diff --git a/src/broadcasting/src/FakePendingBroadcast.php b/src/broadcasting/src/FakePendingBroadcast.php index 21b1417834..18212c5634 100644 --- a/src/broadcasting/src/FakePendingBroadcast.php +++ b/src/broadcasting/src/FakePendingBroadcast.php @@ -17,6 +17,8 @@ public function __construct() /** * Broadcast the event using a specific broadcaster. + * + * @return $this */ public function via(UnitEnum|string|null $connection = null): static { @@ -25,6 +27,8 @@ public function via(UnitEnum|string|null $connection = null): static /** * Broadcast the event to everyone except the current user. + * + * @return $this */ public function toOthers(): static { diff --git a/src/broadcasting/src/InteractsWithBroadcasting.php b/src/broadcasting/src/InteractsWithBroadcasting.php index 10f223f4e3..479fd32fa4 100644 --- a/src/broadcasting/src/InteractsWithBroadcasting.php +++ b/src/broadcasting/src/InteractsWithBroadcasting.php @@ -18,6 +18,8 @@ trait InteractsWithBroadcasting /** * Broadcast the event using a specific broadcaster. + * + * @return $this */ public function broadcastVia(UnitEnum|array|string|null $connection = null): static { diff --git a/src/broadcasting/src/InteractsWithSockets.php b/src/broadcasting/src/InteractsWithSockets.php index e902547c6a..fd3aa03ac4 100644 --- a/src/broadcasting/src/InteractsWithSockets.php +++ b/src/broadcasting/src/InteractsWithSockets.php @@ -15,6 +15,8 @@ trait InteractsWithSockets /** * Exclude the current user from receiving the broadcast. + * + * @return $this */ public function dontBroadcastToCurrentUser(): static { @@ -25,6 +27,8 @@ public function dontBroadcastToCurrentUser(): static /** * Broadcast the event to everyone. + * + * @return $this */ public function broadcastToEveryone(): static { diff --git a/src/broadcasting/src/PendingBroadcast.php b/src/broadcasting/src/PendingBroadcast.php index c9125c4ad9..31ac63bc8f 100644 --- a/src/broadcasting/src/PendingBroadcast.php +++ b/src/broadcasting/src/PendingBroadcast.php @@ -22,6 +22,8 @@ public function __construct( /** * Broadcast the event using a specific broadcaster. + * + * @return $this */ public function via(UnitEnum|string|null $connection = null): static { @@ -36,6 +38,8 @@ public function via(UnitEnum|string|null $connection = null): static /** * Broadcast the event to everyone except the current user. + * + * @return $this */ public function toOthers(): static { diff --git a/src/bus/src/Batchable.php b/src/bus/src/Batchable.php index 4c5ba4e10c..395e948fc3 100644 --- a/src/bus/src/Batchable.php +++ b/src/bus/src/Batchable.php @@ -51,6 +51,8 @@ public function batching(): bool /** * Set the batch ID on the job. + * + * @return $this */ public function withBatchId(string $batchId): static { diff --git a/src/bus/src/ChainDefaults.php b/src/bus/src/ChainDefaults.php new file mode 100644 index 0000000000..8fec2b5809 --- /dev/null +++ b/src/bus/src/ChainDefaults.php @@ -0,0 +1,24 @@ +getAttributeValue($job, $attribute, $property); + + return $value === null || $value === ''; + } +} diff --git a/src/bus/src/ChainedBatch.php b/src/bus/src/ChainedBatch.php index 90ead25d56..a4d2fafdb1 100644 --- a/src/bus/src/ChainedBatch.php +++ b/src/bus/src/ChainedBatch.php @@ -9,6 +9,8 @@ use Hypervel\Contracts\Bus\QueueingDispatcher; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Foundation\Bus\Dispatchable; +use Hypervel\Queue\Attributes\Connection; +use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Support\Collection; use Throwable; @@ -114,10 +116,14 @@ protected function attachRemainderOfChainToEndOfBatch(PendingBatch $batch): Pend $next->chained = $this->chained; - $next->onConnection($next->connection === null || $next->connection === '' + // Laravel checks only the job's properties, so a chain default replaced the job's own + // #[Connection] or #[Queue]. The job's own attribute or property beats the chain default. + $defaults = new ChainDefaults; + + $next->onConnection($defaults->appliesTo($next, Connection::class, 'connection') ? $this->chainConnection : $next->connection); - $next->onQueue($next->queue === null || $next->queue === '' + $next->onQueue($defaults->appliesTo($next, QueueAttribute::class, 'queue') ? $this->chainQueue : $next->queue); diff --git a/src/bus/src/DispatchLockContext.php b/src/bus/src/DispatchLockContext.php index 5896ca77d8..8361964c24 100644 --- a/src/bus/src/DispatchLockContext.php +++ b/src/bus/src/DispatchLockContext.php @@ -118,7 +118,6 @@ public static function registerUnique( ?Closure $releaseContext = null, ): void { /** @var WeakMap $records */ - // @phpstan-ignore assign.propertyType (PHPStan rejects an empty WeakMap for this invariant closed-shape value.) $records = static::$records ??= new WeakMap; $record = $records[$job] ?? static::newRecord(); @@ -148,7 +147,6 @@ public static function registerDebounce( ?Closure $releaseContext = null, ): void { /** @var WeakMap $records */ - // @phpstan-ignore assign.propertyType (PHPStan rejects an empty WeakMap for this invariant closed-shape value.) $records = static::$records ??= new WeakMap; $record = $records[$job] ?? static::newRecord(); $record['debounce'] = compact('cache', 'key', 'owner'); diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php index c3d43c45a3..2ef692789f 100644 --- a/src/bus/src/Dispatcher.php +++ b/src/bus/src/Dispatcher.php @@ -299,6 +299,8 @@ protected function queueRoutesContainer(): Container * Boot-only. The pipes persist on the singleton dispatcher for the worker * lifetime and apply to every subsequent dispatch; per-request use races * across coroutines. + * + * @return $this */ public function pipeThrough(array $pipes): static { @@ -312,6 +314,8 @@ public function pipeThrough(array $pipes): static * * Boot-only. The handler map persists on the singleton dispatcher for the * worker lifetime; per-request use races across coroutines. + * + * @return $this */ public function map(array $map): static { @@ -325,6 +329,8 @@ public function map(array $map): static * * Boot-only. Toggles a flag on the singleton dispatcher for the worker * lifetime; per-request use races across coroutines. + * + * @return $this */ public function withDispatchingAfterResponses(): static { @@ -338,6 +344,8 @@ public function withDispatchingAfterResponses(): static * * Boot-only. Toggles a flag on the singleton dispatcher for the worker * lifetime; per-request use races across coroutines. + * + * @return $this */ public function withoutDispatchingAfterResponses(): static { diff --git a/src/bus/src/PendingBatch.php b/src/bus/src/PendingBatch.php index 74d9495ba3..feb13f8371 100644 --- a/src/bus/src/PendingBatch.php +++ b/src/bus/src/PendingBatch.php @@ -56,6 +56,8 @@ public function __construct( /** * Add jobs to the batch. + * + * @return $this */ public function add(iterable|object $jobs): static { @@ -79,7 +81,7 @@ protected function ensureJobIsBatchable(object|array $job): void { foreach (Arr::wrap($job) as $job) { if ($job instanceof PendingBatch || $job instanceof Closure) { - return; + continue; } if (! (static::$batchableClasses[$job::class] ?? false) && ! isset(class_uses_recursive($job)[Batchable::class])) { @@ -94,6 +96,8 @@ protected function ensureJobIsBatchable(object|array $job): void /** * Add a callback to be executed when the batch is stored. + * + * @return $this */ public function before(callable $callback): static { @@ -112,6 +116,8 @@ public function beforeCallbacks(): array /** * Add a callback to be executed after a job in the batch have executed successfully. + * + * @return $this */ public function progress(callable $callback): static { @@ -130,6 +136,8 @@ public function progressCallbacks(): array /** * Add a callback to be executed after all jobs in the batch have executed successfully. + * + * @return $this */ public function then(callable $callback): static { @@ -148,6 +156,8 @@ public function thenCallbacks(): array /** * Add a callback to be executed after the first failing job in the batch. + * + * @return $this */ public function catch(callable $callback): static { @@ -166,6 +176,8 @@ public function catchCallbacks(): array /** * Add a callback to be executed after the batch has finished executing. + * + * @return $this */ public function finally(callable $callback): static { @@ -188,6 +200,7 @@ public function finallyCallbacks(): array * Optionally, add callbacks to be executed upon each job failure. * * @param array|bool|(callable(Batch, ?Throwable): mixed) $param + * @return $this */ public function allowFailures(mixed $param = true): static { @@ -236,6 +249,8 @@ private function registerCallback(string $type, Closure|callable $callback): voi /** * Set the name for the batch. + * + * @return $this */ public function name(string $name): static { @@ -246,6 +261,8 @@ public function name(string $name): static /** * Specify the queue connection that the batched jobs should run on. + * + * @return $this */ public function onConnection(UnitEnum|string $connection): static { @@ -266,6 +283,8 @@ public function connection(): ?string /** * Specify the queue that the batched jobs should run on. + * + * @return $this */ public function onQueue(UnitEnum|string|null $queue): static { @@ -286,6 +305,8 @@ public function queue(): ?string /** * Dispatch batched jobs through the transactional outbox by default. + * + * @return $this */ public function viaOutbox(): static { @@ -296,6 +317,8 @@ public function viaOutbox(): static /** * Dispatch batched jobs without the transactional outbox by default. + * + * @return $this */ public function withoutOutbox(): static { @@ -308,6 +331,8 @@ public function withoutOutbox(): static /** * Stage batched jobs on the given database connection by default. + * + * @return $this */ public function onOutboxDatabase(UnitEnum|string $connection): static { @@ -325,6 +350,8 @@ public function onOutboxDatabase(UnitEnum|string $connection): static /** * Add additional data into the batch's options array. + * + * @return $this */ public function withOption(string $key, mixed $value): static { diff --git a/src/bus/src/Queueable.php b/src/bus/src/Queueable.php index 15678c77fe..04d644bcd8 100644 --- a/src/bus/src/Queueable.php +++ b/src/bus/src/Queueable.php @@ -7,6 +7,8 @@ use Closure; use DateInterval; use DateTimeInterface; +use Hypervel\Queue\Attributes\Connection; +use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Queue\CallQueuedClosure; use Hypervel\Support\Arr; use Hypervel\Support\Collection; @@ -95,6 +97,8 @@ trait Queueable /** * Set the desired connection for the job. + * + * @return $this */ public function onConnection(UnitEnum|string|null $connection): static { @@ -107,6 +111,8 @@ public function onConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the job. + * + * @return $this */ public function onQueue(UnitEnum|string|null $queue): static { @@ -121,6 +127,8 @@ public function onQueue(UnitEnum|string|null $queue): static * Set the desired job message group. * * This feature is only supported by some queues, such as Amazon SQS. + * + * @return $this */ public function onGroup(array|UnitEnum|string|int|null $group): static { @@ -133,6 +141,8 @@ public function onGroup(array|UnitEnum|string|int|null $group): static * Set the desired job deduplicator callback. * * This feature is only supported by some queues, such as Amazon SQS FIFO. + * + * @return $this */ public function withDeduplicator(array|callable|null $deduplicator): static { @@ -151,6 +161,8 @@ public function withDeduplicator(array|callable|null $deduplicator): static /** * Set the desired connection for the chain. + * + * @return $this */ public function allOnConnection(UnitEnum|string|null $connection): static { @@ -166,6 +178,8 @@ public function allOnConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the chain. + * + * @return $this */ public function allOnQueue(UnitEnum|string|null $queue): static { @@ -181,6 +195,8 @@ public function allOnQueue(UnitEnum|string|null $queue): static /** * Set the desired delay in seconds for the job. + * + * @return $this */ public function delay(array|DateInterval|DateTimeInterface|int|null $delay): static { @@ -191,6 +207,8 @@ public function delay(array|DateInterval|DateTimeInterface|int|null $delay): sta /** * Set the delay for the job to zero seconds. + * + * @return $this */ public function withoutDelay(): static { @@ -201,6 +219,8 @@ public function withoutDelay(): static /** * Indicate that the job should be dispatched after the open parent database transactions have committed. + * + * @return $this */ public function afterCommit(): static { @@ -211,6 +231,8 @@ public function afterCommit(): static /** * Indicate that the job should not wait until database transactions have been committed before dispatching. + * + * @return $this */ public function beforeCommit(): static { @@ -221,6 +243,8 @@ public function beforeCommit(): static /** * Specify the middleware the job should be dispatched through. + * + * @return $this */ public function through(array|object $middleware): static { @@ -231,6 +255,8 @@ public function through(array|object $middleware): static /** * Set the jobs that should run if this job is successful. + * + * @return $this */ public function chain(array $chain): static { @@ -243,6 +269,8 @@ public function chain(array $chain): static /** * Prepend a job to the current chain so that it is run after the currently running job. + * + * @return $this */ public function prependToChain(mixed $job): static { @@ -257,6 +285,8 @@ public function prependToChain(mixed $job): static /** * Append a job to the end of the current chain. + * + * @return $this */ public function appendToChain(mixed $job): static { @@ -298,10 +328,14 @@ public function dispatchNextJobInChain(): void dispatch(tap(unserialize(array_shift($this->chained)), function ($next) { $next->chained = $this->chained; - $next->onConnection($next->connection === null || $next->connection === '' + // Laravel checks only the job's properties, so a chain default replaced the job's own + // #[Connection] or #[Queue]. The job's own attribute or property beats the chain default. + $defaults = new ChainDefaults; + + $next->onConnection($defaults->appliesTo($next, Connection::class, 'connection') ? $this->chainConnection : $next->connection); - $next->onQueue($next->queue === null || $next->queue === '' + $next->onQueue($defaults->appliesTo($next, QueueAttribute::class, 'queue') ? $this->chainQueue : $next->queue); diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index 8c2e8aa53f..b88cf6d6b3 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -515,6 +515,8 @@ public function setDefaultDriver(UnitEnum|string $name): void * Boot or tests only. Mutates the singleton's store cache; concurrent * coroutines may already hold a reference to the store and next resolution * will rebuild with fresh connections. + * + * @return $this */ public function forgetDriver(array|UnitEnum|string|null $name = null): static { @@ -584,6 +586,8 @@ public function extend(string $driver, Closure $callback): static * Tests only. Swaps the singleton's container reference; per-request use * races across coroutines and breaks every concurrent request resolving * stores through this manager. + * + * @return $this */ public function setApplication(Container $app): static { @@ -602,6 +606,7 @@ public function setApplication(Container $app): static * contributions without memoizing them. * * @param Closure(): array $resolver + * @return $this * * @throws LogicException */ diff --git a/src/cache/src/DatabaseStore.php b/src/cache/src/DatabaseStore.php index 5cb4a8aa12..154bf70114 100644 --- a/src/cache/src/DatabaseStore.php +++ b/src/cache/src/DatabaseStore.php @@ -447,6 +447,8 @@ public function getConnection(): ConnectionInterface * * Boot-only. Persists on the cached store for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function setConnection(string $connectionName): static { @@ -468,6 +470,8 @@ public function getLockConnection(): ConnectionInterface * * Boot-only. Persists on the cached store for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function setLockConnection(string $connectionName): static { diff --git a/src/cache/src/Events/CacheEvent.php b/src/cache/src/Events/CacheEvent.php index 2f5df69ded..c29c20090d 100644 --- a/src/cache/src/Events/CacheEvent.php +++ b/src/cache/src/Events/CacheEvent.php @@ -37,6 +37,8 @@ public function __construct(?string $storeName, UnitEnum|string $key, array $tag /** * Set the tags for the cache event. + * + * @return $this */ public function setTags(array $tags): static { diff --git a/src/cache/src/Events/CacheFlushFailed.php b/src/cache/src/Events/CacheFlushFailed.php index b3613a8df1..e3321d2f4e 100644 --- a/src/cache/src/Events/CacheFlushFailed.php +++ b/src/cache/src/Events/CacheFlushFailed.php @@ -22,6 +22,8 @@ public function __construct( /** * Set the tags for the cache event. + * + * @return $this */ public function setTags(array $tags): static { diff --git a/src/cache/src/Events/CacheFlushed.php b/src/cache/src/Events/CacheFlushed.php index 7c85541a56..6f386acc46 100644 --- a/src/cache/src/Events/CacheFlushed.php +++ b/src/cache/src/Events/CacheFlushed.php @@ -17,6 +17,8 @@ public function __construct( /** * Set the tags for the cache event. + * + * @return $this */ public function setTags(array $tags): static { diff --git a/src/cache/src/Events/CacheFlushing.php b/src/cache/src/Events/CacheFlushing.php index 17ad942909..3b07baf01e 100644 --- a/src/cache/src/Events/CacheFlushing.php +++ b/src/cache/src/Events/CacheFlushing.php @@ -17,6 +17,8 @@ public function __construct( /** * Set the tags for the cache event. + * + * @return $this */ public function setTags(array $tags): static { diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 662f57e021..a9d8a42d94 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -14,7 +14,6 @@ use Hypervel\Filesystem\LockableFile; use Hypervel\Support\InteractsWithTime; use RuntimeException; -use Swoole\Coroutine\CanceledException; class FileStore implements CanFlushLocks, LockProvider, Store { @@ -242,9 +241,11 @@ public function remainingSeconds(string $key): ?float { try { $expire = (int) substr($this->files->get($this->path($key), true), 0, 10); - } catch (CanceledException $exception) { - throw $exception; - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return null; } @@ -426,6 +427,8 @@ public function getDirectory(): string * * Boot-only. Persists on the cached store for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function setDirectory(string $directory): static { @@ -439,6 +442,8 @@ public function setDirectory(string $directory): static * * Boot-only. Persists on the cached store for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function setLockDirectory(?string $lockDirectory): static { @@ -524,9 +529,11 @@ protected function getPayload(string $key): array 0, 10 ); - } catch (CanceledException $exception) { - throw $exception; - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return $this->emptyPayload(); } diff --git a/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php b/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php index da8de9a7fc..c63fc4b5ba 100644 --- a/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php +++ b/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php @@ -54,6 +54,8 @@ public function __construct( /** * Set the maximum number of locks that can be obtained per time window. + * + * @return $this */ public function limit(int $maxLocks): static { @@ -64,6 +66,8 @@ public function limit(int $maxLocks): static /** * Set the number of seconds until the lock will be released. + * + * @return $this */ public function releaseAfter(DateInterval|DateTimeInterface|int $releaseAfter): static { @@ -74,6 +78,8 @@ public function releaseAfter(DateInterval|DateTimeInterface|int $releaseAfter): /** * Set the number of seconds to block until a lock is available. + * + * @return $this */ public function block(int $timeout): static { @@ -84,6 +90,8 @@ public function block(int $timeout): static /** * The number of milliseconds to wait between lock acquisition attempts. + * + * @return $this */ public function sleep(int $sleep): static { diff --git a/src/cache/src/Lock.php b/src/cache/src/Lock.php index b626ef017c..38c149a778 100644 --- a/src/cache/src/Lock.php +++ b/src/cache/src/Lock.php @@ -192,6 +192,8 @@ public function isOwnedBy(?string $owner): bool /** * Specify the number of milliseconds to sleep in between blocked lock acquisition attempts. + * + * @return $this */ public function betweenBlockedAttemptsSleepFor(int $milliseconds): static { diff --git a/src/cache/src/Redis/Console/BenchmarkCommand.php b/src/cache/src/Redis/Console/BenchmarkCommand.php index 31d5f6ee34..88cefe4352 100644 --- a/src/cache/src/Redis/Console/BenchmarkCommand.php +++ b/src/cache/src/Redis/Console/BenchmarkCommand.php @@ -25,6 +25,7 @@ use Hypervel\Console\Prohibitable; use Hypervel\Contracts\Cache\Factory as CacheContract; use Hypervel\Redis\RedisConnection; +use Hypervel\Support\Sleep; use Hypervel\Support\SystemInfo; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -362,7 +363,7 @@ protected function runSuiteWithRuns(TagMode $tagMode, BenchmarkContext $context, $this->newLine(); $this->line('Pausing 1 second before next run...'); $this->newLine(); - sleep(1); + Sleep::sleep(1); } } diff --git a/src/cache/src/RedisStore.php b/src/cache/src/RedisStore.php index 7e7d6ed443..297c0485e1 100644 --- a/src/cache/src/RedisStore.php +++ b/src/cache/src/RedisStore.php @@ -331,6 +331,8 @@ public function flushStaleTags(): ?array * * Boot-only. Mutates state on a per-worker singleton; runtime mutation * races across coroutines. + * + * @return $this */ public function setTagMode(TagMode|string $mode): static { @@ -404,6 +406,8 @@ public function setConnection(?string $connection): void * * Boot-only. Persists on the cached store for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function setLockConnection(string $connection): static { diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 7bad2ff81d..e150eb2ac6 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -1089,6 +1089,7 @@ public function getName(): ?string * and queued unique-lock cleanup in every coroutine using that repository. * * @internal + * @return $this */ public function setName(string $name): static { diff --git a/src/cache/src/StackStore.php b/src/cache/src/StackStore.php index 1d51ad6208..1c04769dab 100644 --- a/src/cache/src/StackStore.php +++ b/src/cache/src/StackStore.php @@ -152,7 +152,7 @@ static function (StackStoreProxy $store) use ($values): void { } catch (Throwable $exception) { if (Coroutine::causedByCancellation($exception)) { // Finish this layer's cleanup, as callStores() does across layers. - $cancellation ??= $exception; + $cancellation = Coroutine::preferCancellation($cancellation, $exception); } else { // An ordinary failure usually means the layer is unreachable, so stop // instead of paying a timeout per key. callStores() discards these. @@ -689,7 +689,7 @@ protected function callStores(Closure $handler, ?Closure $rollback = null, bool } catch (Throwable $cleanupException) { // Preserve the write failure unless cleanup was canceled. if (Coroutine::causedByCancellation($cleanupException)) { - $cancellation ??= $cleanupException; + $cancellation = Coroutine::preferCancellation($cancellation, $cleanupException); } } } diff --git a/src/cache/src/SwooleStore.php b/src/cache/src/SwooleStore.php index 976db85580..0dbd8803eb 100644 --- a/src/cache/src/SwooleStore.php +++ b/src/cache/src/SwooleStore.php @@ -10,11 +10,11 @@ use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\CarbonImmutable; use InvalidArgumentException; use Laravel\SerializableClosure\SerializableClosure; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class SwooleStore implements CanFlushLocks, LockProvider, Store @@ -292,12 +292,8 @@ function () use ($metadataKey, $metadata): bool { } catch (Throwable $failure) { try { $this->state->withRowLock($metadataKey, fn (): bool => $this->rawForget($metadataKey)); - } catch (CanceledException $cleanupCancellation) { - if (! $failure instanceof CanceledException) { - throw $cleanupCancellation; - } - } catch (Throwable) { - // Preserve the registration failure as primary. + } catch (Throwable $cleanupFailure) { + $failure = Coroutine::preferCancellation($failure, $cleanupFailure); } throw $failure; @@ -933,16 +929,12 @@ protected function refreshIntervalCache(string $metadataKey, bool $force = false if ($claimedAt !== null) { try { $this->clearIntervalClaim($metadataKey, $claimedAt); - } catch (CanceledException $cleanupCancellation) { - if (! $failure instanceof CanceledException) { - throw $cleanupCancellation; - } - } catch (Throwable) { - // Preserve the refresh failure as primary. + } catch (Throwable $cleanupFailure) { + $failure = Coroutine::preferCancellation($failure, $cleanupFailure); } } - if ($failure instanceof CanceledException) { + if (Coroutine::causedByCancellation($failure)) { throw $failure; } diff --git a/src/collections/src/Arr.php b/src/collections/src/Arr.php index b74262e8ce..b923585671 100644 --- a/src/collections/src/Arr.php +++ b/src/collections/src/Arr.php @@ -737,7 +737,7 @@ public static function select(array $array, array|string|int|float|null $keys): /** * Pluck an array of values from an array. */ - public static function pluck(iterable $array, string|array|int|Closure|null $value, string|array|Closure|null $key = null): array + public static function pluck(iterable $array, string|array|int|Closure|null $value, string|array|int|Closure|null $key = null): array { $results = []; @@ -772,21 +772,27 @@ public static function pluck(iterable $array, string|array|int|Closure|null $val /** * Explode the "value" and "key" arguments passed to "pluck". * - * @param array|Closure|string $value - * @param null|array|Closure|string $key - * @return array + * @return array{null|array|Closure|int, null|array|Closure|int} */ - protected static function explodePluckParameters($value, $key) + protected static function explodePluckParameters(string|array|int|Closure|null $value, string|array|int|Closure|null $key): array { $value = is_string($value) ? explode('.', $value) : $value; - $key = is_null($key) || is_array($key) || $key instanceof Closure ? $key : explode('.', $key); + $key = is_null($key) || is_array($key) || is_int($key) || $key instanceof Closure ? $key : explode('.', $key); return [$value, $key]; } /** * Run a map over each of the items in the array. + * + * @template TKey of array-key + * @template TValue + * @template TMapValue + * + * @param array $array + * @param callable(TValue, TKey): TMapValue $callback + * @return array */ public static function map(array $array, callable $callback): array { @@ -795,6 +801,7 @@ public static function map(array $array, callable $callback): array try { $items = array_map($callback, $array, $keys); } catch (ArgumentCountError) { + // @phpstan-ignore argument.type (only reached by callbacks that reject the key argument, such as internal functions) $items = array_map($callback, $array); } @@ -806,13 +813,14 @@ public static function map(array $array, callable $callback): array * * The callback should return an associative array with a single key/value pair. * - * @template TKey + * @template TKey of array-key * @template TValue * @template TMapWithKeysKey of array-key * @template TMapWithKeysValue * * @param array $array * @param callable(TValue, TKey): array $callback + * @return array */ public static function mapWithKeys(array $array, callable $callback): array { diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index 702d4c200b..684d15b757 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -363,7 +363,7 @@ public function duplicates(callable|string|null $callback = null, bool $strict = if ($uniqueItems->isNotEmpty() && $compare($value, $uniqueItems->first())) { $uniqueItems->shift(); } else { - // @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess) + // @phpstan-ignore offsetAssign.dimType (PHPStan rejects template keys on ArrayAccess) $duplicates[$key] = $value; } } @@ -504,13 +504,14 @@ public function get(mixed $key, mixed $default = null): mixed /** * Get an item from the collection by key or add it to collection if it does not exist. * + * @template TGetOrPutKey of array-key * @template TGetOrPutValue * - * @param null|TKey $key + * @param null|TGetOrPutKey $key * @param (Closure(): TGetOrPutValue)|TGetOrPutValue $value * @return TGetOrPutValue|TValue * - * @phpstan-this-out ($key is null ? static : static) + * @phpstan-this-out ($key is null ? static : static) */ public function getOrPut(mixed $key, mixed $value): mixed { @@ -586,6 +587,12 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal /** * Key an associative array by a field or using a callback. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TNewKey of array-key|null|UnitEnum|BaseStringable + * + * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy + * @return static<($keyBy is (array|string) ? array-key : (TNewKey is array-key ? TNewKey : (TNewKey is (null|BaseStringable) ? string : array-key))), TValue> */ #[Override] public function keyBy(callable|array|string $keyBy): static @@ -785,9 +792,12 @@ public function last(?callable $callback = null, mixed $default = null): mixed /** * Get the values of a given key. * - * @param null|array|Closure|int|string $value - * @param null|array|Closure|int|string $key - * @return static + * @template TPluckValue + * @template TPluckKey of array-key|BaseStringable + * + * @param null|array|(Closure(TValue): TPluckValue)|int|string $value + * @param null|array|(Closure(TValue): TPluckKey)|int|string $key + * @return static<($key is Closure ? (TPluckKey is int ? int : string) : array-key), ($value is Closure ? TPluckValue : mixed)> */ public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null): Collection { @@ -861,7 +871,7 @@ public function mapWithKeys(callable $callback): Collection * @template TMergeValue * * @param Arrayable>|iterable $items - * @return static + * @return static<(array is array ? int : TKey), TMergeValue|TValue> */ public function merge(mixed $items): static { @@ -874,7 +884,7 @@ public function merge(mixed $items): static * @template TMergeRecursiveValue * * @param Arrayable>|iterable $items - * @return static|TMergeRecursiveValue|TValue)> + * @return static<(array is array ? int : TKey), (TKey is int ? TMergeRecursiveValue|TValue : array|TMergeRecursiveValue|TValue)> */ public function mergeRecursive(mixed $items): static { @@ -883,6 +893,8 @@ public function mergeRecursive(mixed $items): static /** * Multiply the items in the collection by the multiplier. + * + * @return static */ public function multiply(int $multiplier): static { @@ -901,8 +913,7 @@ public function multiply(int $multiplier): static * @template TCombineValue * * @param Arrayable>|iterable $values - * @return static - * @phpstan-ignore generics.notSubtype (TValue becomes key - only valid when TValue is array-key, but can't express this constraint) + * @return static<(TValue is array-key ? TValue : array-key), TCombineValue> */ public function combine(mixed $values): static { @@ -1068,11 +1079,10 @@ public function unshift(mixed ...$values): static /** * Push all of the given items onto the collection. * - * @template TConcatKey of array-key * @template TConcatValue * - * @param iterable $source - * @return static + * @param iterable $source + * @return static */ public function concat(iterable $source): static { @@ -1233,6 +1243,8 @@ public function after(mixed $value, bool $strict = false): mixed * @param int<0, max> $count * @return ($count is 1 ? null|TValue : static) * + * @phpstan-this-out static<(array is array ? int : TKey), TValue> + * * @throws InvalidArgumentException */ public function shift(int $count = 1): mixed @@ -1266,6 +1278,8 @@ public function shift(int $count = 1): mixed /** * Shuffle the items in the collection. + * + * @return static */ public function shuffle(): static { @@ -1334,7 +1348,7 @@ public function slice(int $offset, ?int $length = null): static /** * Split a collection into a certain number of groups. * - * @return ($numberOfGroups is positive-int ? static : never) + * @return ($numberOfGroups is positive-int ? static is array ? int : TKey), TValue>> : never) * * @throws InvalidArgumentException */ @@ -1348,8 +1362,8 @@ public function split(int $numberOfGroups): static return $this->newInstance(); } - /** @var static> $groups */ - $groups = $this->newInstance(); + /** @var static is array ? int : TKey), TValue>> $groups */ + $groups = $this->newInstance(); // @phpstan-ignore varTag.nativeType (@TODO: Remove once PHPStan stops applying receiver generics to the factory's native static return type.) $groupSize = (int) floor($this->count() / $numberOfGroups); @@ -1491,7 +1505,7 @@ public function chunk(int $size, bool $preserveKeys = true): static public function chunkWhile(callable $callback): static { /** @var static> $chunks */ - $chunks = $this->newInstance(); + $chunks = $this->newInstance(); // @phpstan-ignore varTag.nativeType (@TODO: Remove once PHPStan stops applying receiver generics to the factory's native static return type.) /** @var static $chunk */ $chunk = $this->newInstance(); @@ -1504,7 +1518,7 @@ public function chunkWhile(callable $callback): static $chunk = $this->newInstance(); } - // @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess) + // @phpstan-ignore offsetAssign.dimType (PHPStan rejects template keys on ArrayAccess) $chunk[$key] = $value; } @@ -1710,6 +1724,10 @@ public function sortKeysUsing(callable $callback): static /** * Splice a portion of the underlying collection array. + * + * @return static<(array is array ? int : TKey), TValue> + * + * @phpstan-this-out static<(array is array ? int : TKey), TValue> */ public function splice(int $offset, ?int $length = null, mixed $replacement = []): static { diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php index e344957ebb..a00d9563d0 100644 --- a/src/collections/src/Enumerable.php +++ b/src/collections/src/Enumerable.php @@ -168,6 +168,8 @@ public function dd(mixed ...$args): never; /** * Dump the collection. + * + * @return $this */ public function dump(mixed ...$args): static; @@ -240,6 +242,7 @@ public function duplicatesStrict(callable|string|null $callback = null): Collect * Execute a callback over each item. * * @param callable(TValue, TKey): mixed $callback + * @return $this */ public function each(callable $callback): static; @@ -272,10 +275,10 @@ public function filter(?callable $callback = null): static; /** * Apply the callback if the given "value" is (or resolves to) truthy. * - * @template TWhenReturnType as null + * @template TWhenReturnType = never * - * @param null|(callable($this): TWhenReturnType) $callback - * @param null|(callable($this): TWhenReturnType) $default + * @param null|(callable($this): (null|TWhenReturnType|void)) $callback + * @param null|(callable($this): (null|TWhenReturnType|void)) $default * @return $this|TWhenReturnType */ public function when(mixed $value, ?callable $callback = null, ?callable $default = null): mixed; @@ -283,10 +286,10 @@ public function when(mixed $value, ?callable $callback = null, ?callable $defaul /** * Apply the callback if the collection is empty. * - * @template TWhenEmptyReturnType + * @template TWhenEmptyReturnType = never * - * @param (callable($this): TWhenEmptyReturnType) $callback - * @param null|(callable($this): TWhenEmptyReturnType) $default + * @param (callable($this): (null|TWhenEmptyReturnType|void)) $callback + * @param null|(callable($this): (null|TWhenEmptyReturnType|void)) $default * @return $this|TWhenEmptyReturnType */ public function whenEmpty(callable $callback, ?callable $default = null): mixed; @@ -294,10 +297,10 @@ public function whenEmpty(callable $callback, ?callable $default = null): mixed; /** * Apply the callback if the collection is not empty. * - * @template TWhenNotEmptyReturnType + * @template TWhenNotEmptyReturnType = never * - * @param callable($this): TWhenNotEmptyReturnType $callback - * @param null|(callable($this): TWhenNotEmptyReturnType) $default + * @param callable($this): (null|TWhenNotEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TWhenNotEmptyReturnType|void)) $default * @return $this|TWhenNotEmptyReturnType */ public function whenNotEmpty(callable $callback, ?callable $default = null): mixed; @@ -305,10 +308,10 @@ public function whenNotEmpty(callable $callback, ?callable $default = null): mix /** * Apply the callback if the given "value" is (or resolves to) falsy. * - * @template TUnlessReturnType + * @template TUnlessReturnType = never * - * @param (callable($this): TUnlessReturnType) $callback - * @param null|(callable($this): TUnlessReturnType) $default + * @param (callable($this): (null|TUnlessReturnType|void)) $callback + * @param null|(callable($this): (null|TUnlessReturnType|void)) $default * @return $this|TUnlessReturnType */ public function unless(mixed $value, callable $callback, ?callable $default = null): mixed; @@ -316,10 +319,10 @@ public function unless(mixed $value, callable $callback, ?callable $default = nu /** * Apply the callback unless the collection is empty. * - * @template TUnlessEmptyReturnType + * @template TUnlessEmptyReturnType = never * - * @param callable($this): TUnlessEmptyReturnType $callback - * @param null|(callable($this): TUnlessEmptyReturnType) $default + * @param callable($this): (null|TUnlessEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TUnlessEmptyReturnType|void)) $default * @return $this|TUnlessEmptyReturnType */ public function unlessEmpty(callable $callback, ?callable $default = null): mixed; @@ -327,10 +330,10 @@ public function unlessEmpty(callable $callback, ?callable $default = null): mixe /** * Apply the callback unless the collection is not empty. * - * @template TUnlessNotEmptyReturnType + * @template TUnlessNotEmptyReturnType = never * - * @param callable($this): TUnlessNotEmptyReturnType $callback - * @param null|(callable($this): TUnlessNotEmptyReturnType) $default + * @param callable($this): (null|TUnlessNotEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TUnlessNotEmptyReturnType|void)) $default * @return $this|TUnlessNotEmptyReturnType */ public function unlessNotEmpty(callable $callback, ?callable $default = null): mixed; @@ -458,10 +461,10 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal /** * Key an associative array by a field or using a callback. * - * @template TNewKey of array-key|UnitEnum|BaseStringable + * @template TNewKey of array-key|null|UnitEnum|BaseStringable * * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy - * @return static<($keyBy is (array|string) ? array-key : (TNewKey is array-key ? TNewKey : (TNewKey is BaseStringable ? string : array-key))), TValue> + * @return static<($keyBy is (array|string) ? array-key : (TNewKey is array-key ? TNewKey : (TNewKey is (null|BaseStringable) ? string : array-key))), TValue> */ public function keyBy(callable|array|string $keyBy): static; @@ -633,7 +636,7 @@ public function mapWithKeys(callable $callback): Collection|static; * @template TFlatMapValue * * @param callable(TValue, TKey): (array|Enumerable) $callback - * @return static + * @return static */ public function flatMap(callable $callback): Collection|static; @@ -653,7 +656,7 @@ public function mapInto(string $class): Collection|static; * @template TMergeValue * * @param Arrayable>|iterable $items - * @return static + * @return static<(array is array ? int : TKey), TMergeValue|TValue> */ public function merge(mixed $items): static; @@ -663,7 +666,7 @@ public function merge(mixed $items): static; * @template TMergeRecursiveValue * * @param Arrayable>|iterable $items - * @return static|TMergeRecursiveValue|TValue)> + * @return static<(array is array ? int : TKey), (TKey is int ? TMergeRecursiveValue|TValue : array|TMergeRecursiveValue|TValue)> */ public function mergeRecursive(mixed $items): static; @@ -673,8 +676,7 @@ public function mergeRecursive(mixed $items): static; * @template TCombineValue * * @param Arrayable>|iterable $values - * @return static - * @phpstan-ignore generics.notSubtype (TValue becomes key - only valid when TValue is array-key, but can't express this constraint) + * @return static<(TValue is array-key ? TValue : array-key), TCombineValue> */ public function combine(Arrayable|iterable $values): static; @@ -733,11 +735,10 @@ public function partition(mixed $key, mixed $operator = null, mixed $value = nul /** * Push all of the given items onto the collection. * - * @template TConcatKey of array-key * @template TConcatValue * - * @param iterable $source - * @return static + * @param iterable $source + * @return static */ public function concat(iterable $source): static; @@ -825,6 +826,8 @@ public function after(mixed $value, bool $strict = false): mixed; /** * Shuffle the items in the collection. + * + * @return static */ public function shuffle(): static; @@ -862,7 +865,7 @@ public function slice(int $offset, ?int $length = null): static; /** * Split a collection into a certain number of groups. * - * @return static + * @return static is array ? int : TKey), TValue>> */ public function split(int $numberOfGroups): static; @@ -1001,6 +1004,7 @@ public function takeWhile(mixed $value): static; * Pass the collection to the given callback and then return it. * * @param callable($this): mixed $callback + * @return $this */ public function tap(callable $callback): static; @@ -1034,9 +1038,12 @@ public function pipeThrough(array $pipes): mixed; /** * Get the values of a given key. * - * @param null|array|Closure|int|string $value - * @param null|array|Closure|int|string $key - * @return static + * @template TPluckValue + * @template TPluckKey of array-key|BaseStringable + * + * @param null|array|(Closure(TValue): TPluckValue)|int|string $value + * @param null|array|(Closure(TValue): TPluckKey)|int|string $key + * @return static<($key is Closure ? (TPluckKey is int ? int : string) : array-key), ($value is Closure ? TPluckValue : mixed)> */ public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null): Collection|static; @@ -1166,6 +1173,8 @@ public function __toString(): string; /** * Indicate that the model's string representation should be escaped when __toString is invoked. + * + * @return $this */ public function escapeWhenCastingToString(bool $escape = true): static; diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index 626533b409..6d0408a7a5 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -349,6 +349,15 @@ public function doesntContainStrict(mixed $key, mixed $operator = null, mixed $v return ! $this->containsStrict(...func_get_args()); } + /** + * Cross join with the given lists, returning all possible permutations. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TCrossJoinValue + * + * @param Arrayable>|iterable ...$arrays + * @return static> + */ #[Override] public function crossJoin(Arrayable|iterable ...$arrays): static { @@ -583,6 +592,12 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal /** * Key an associative array by a field or using a callback. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TNewKey of array-key|null|UnitEnum|BaseStringable + * + * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy + * @return static<($keyBy is (array|string) ? array-key : (TNewKey is array-key ? TNewKey : (TNewKey is (null|BaseStringable) ? string : array-key))), TValue> */ #[Override] public function keyBy(callable|array|string $keyBy): static @@ -739,9 +754,12 @@ public function last(?callable $callback = null, mixed $default = null): mixed /** * Get the values of a given key. * - * @param null|array|Closure|int|string $value - * @param null|array|Closure|int|string $key - * @return static + * @template TPluckValue + * @template TPluckKey of array-key|BaseStringable + * + * @param null|array|(Closure(TValue): TPluckValue)|int|string $value + * @param null|array|(Closure(TValue): TPluckKey)|int|string $key + * @return static<($key is Closure ? (TPluckKey is int ? int : string) : array-key), ($value is Closure ? TPluckValue : mixed)> */ public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null): static { @@ -799,6 +817,18 @@ public function mapSpread(callable $callback): static }); } + /** + * Run a dictionary map over the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TMapToDictionaryKey of array-key + * @template TMapToDictionaryValue + * + * @param callable(TValue, TKey): array $callback + * @return static> + */ #[Override] public function mapToDictionary(callable $callback): static { @@ -851,7 +881,7 @@ public function mapWithKeys(callable $callback): static * @template TFlatMapValue * * @param callable(TValue, TKey): (array|Enumerable) $callback - * @return static + * @return static */ public function flatMap(callable $callback): static { @@ -875,6 +905,15 @@ public function mapInto(string $class): static return $this->map(fn ($value, $key) => new $class($value, $key)); } + /** + * Merge the collection with the given items. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TMergeValue + * + * @param Arrayable>|iterable $items + * @return static<(array is array ? int : TKey), TMergeValue|TValue> + */ #[Override] public function merge(mixed $items): static { @@ -882,6 +921,15 @@ public function merge(mixed $items): static return $this->passthru(__FUNCTION__, func_get_args()); } + /** + * Recursively merge the collection with the given items. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template TMergeRecursiveValue + * + * @param Arrayable>|iterable $items + * @return static<(array is array ? int : TKey), (TKey is int ? TMergeRecursiveValue|TValue : array|TMergeRecursiveValue|TValue)> + */ #[Override] public function mergeRecursive(mixed $items): static { @@ -891,9 +939,12 @@ public function mergeRecursive(mixed $items): static /** * Multiply the items in the collection by the multiplier. + * + * @return static */ public function multiply(int $multiplier): static { + // @phpstan-ignore return.type (passthru loses generic type info) return $this->passthru(__FUNCTION__, func_get_args()); } @@ -903,8 +954,7 @@ public function multiply(int $multiplier): static * @template TCombineValue = mixed * * @param Arrayable>|(callable(): iterable)|iterable $values - * @return static - * @phpstan-ignore generics.notSubtype (TValue becomes key - only valid when TValue is array-key, but can't express this constraint) + * @return static<(TValue is array-key ? TValue : array-key), TCombineValue> */ public function combine(Arrayable|iterable|callable $values): static { @@ -1058,11 +1108,10 @@ public function partition(mixed $key, mixed $operator = null, mixed $value = nul /** * Push all of the given items onto the collection. * - * @template TConcatKey of array-key * @template TConcatValue * - * @param iterable $source - * @return static + * @param iterable $source + * @return static */ public function concat(iterable $source): static { @@ -1209,6 +1258,7 @@ public function after(mixed $value, bool $strict = false): mixed #[Override] public function shuffle(): static { + // @phpstan-ignore return.type (passthru loses generic type info) return $this->passthru(__FUNCTION__, []); } @@ -1331,6 +1381,8 @@ public function slice(int $offset, ?int $length = null): static /** * Split a collection into a certain number of groups. * + * @return ($numberOfGroups is positive-int ? static is array ? int : TKey), TValue>> : never) + * * @throws InvalidArgumentException */ #[Override] @@ -1480,7 +1532,7 @@ public function chunkWhile(callable $callback): static $chunk = new Collection; if ($iterator->valid()) { - // @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess) + // @phpstan-ignore offsetAssign.dimType (PHPStan rejects template keys on ArrayAccess) $chunk[$iterator->key()] = $iterator->current(); $iterator->next(); @@ -1493,7 +1545,7 @@ public function chunkWhile(callable $callback): static $chunk = new Collection; } - // @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess) + // @phpstan-ignore offsetAssign.dimType (PHPStan rejects template keys on ArrayAccess) $chunk[$iterator->key()] = $iterator->current(); $iterator->next(); @@ -1967,6 +2019,7 @@ protected function usleep(int $microseconds): void class_exists(Sleep::class) ? Sleep::usleep($microseconds) + // @phpstan-ignore hypervel.rawSleep (fallback when collections is installed without hypervel/support) : usleep($microseconds); } diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php index 70debc67ba..3ac00baed3 100644 --- a/src/collections/src/Traits/EnumeratesValues.php +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -248,6 +248,8 @@ public function dd(mixed ...$args): never /** * Dump the items. + * + * @return $this */ public function dump(mixed ...$args): static { @@ -260,6 +262,7 @@ public function dump(mixed ...$args): static * Execute a callback over each item. * * @param callable(TValue, TKey): mixed $callback + * @return $this */ public function each(callable $callback): static { @@ -430,7 +433,7 @@ public function mapToGroups(callable $callback): Collection|static * @template TFlatMapValue * * @param callable(TValue, TKey): (array|Enumerable) $callback - * @return Collection|static + * @return Collection<(array is array ? int : TFlatMapKey), TFlatMapValue>|static<(array is array ? int : TFlatMapKey), TFlatMapValue> */ public function flatMap(callable $callback): Collection|static { @@ -555,10 +558,10 @@ public function sum(callable|int|string|null $callback = null): mixed /** * Apply the callback if the collection is empty. * - * @template TWhenEmptyReturnType + * @template TWhenEmptyReturnType = never * - * @param (callable($this): TWhenEmptyReturnType) $callback - * @param null|(callable($this): TWhenEmptyReturnType) $default + * @param (callable($this): (null|TWhenEmptyReturnType|void)) $callback + * @param null|(callable($this): (null|TWhenEmptyReturnType|void)) $default * @return $this|TWhenEmptyReturnType */ public function whenEmpty(callable $callback, ?callable $default = null): mixed @@ -569,10 +572,10 @@ public function whenEmpty(callable $callback, ?callable $default = null): mixed /** * Apply the callback if the collection is not empty. * - * @template TWhenNotEmptyReturnType + * @template TWhenNotEmptyReturnType = never * - * @param callable($this): TWhenNotEmptyReturnType $callback - * @param null|(callable($this): TWhenNotEmptyReturnType) $default + * @param callable($this): (null|TWhenNotEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TWhenNotEmptyReturnType|void)) $default * @return $this|TWhenNotEmptyReturnType */ public function whenNotEmpty(callable $callback, ?callable $default = null): mixed @@ -583,10 +586,10 @@ public function whenNotEmpty(callable $callback, ?callable $default = null): mix /** * Apply the callback unless the collection is empty. * - * @template TUnlessEmptyReturnType + * @template TUnlessEmptyReturnType = never * - * @param callable($this): TUnlessEmptyReturnType $callback - * @param null|(callable($this): TUnlessEmptyReturnType) $default + * @param callable($this): (null|TUnlessEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TUnlessEmptyReturnType|void)) $default * @return $this|TUnlessEmptyReturnType */ public function unlessEmpty(callable $callback, ?callable $default = null): mixed @@ -597,10 +600,10 @@ public function unlessEmpty(callable $callback, ?callable $default = null): mixe /** * Apply the callback unless the collection is not empty. * - * @template TUnlessNotEmptyReturnType + * @template TUnlessNotEmptyReturnType = never * - * @param callable($this): TUnlessNotEmptyReturnType $callback - * @param null|(callable($this): TUnlessNotEmptyReturnType) $default + * @param callable($this): (null|TUnlessNotEmptyReturnType|void) $callback + * @param null|(callable($this): (null|TUnlessNotEmptyReturnType|void)) $default * @return $this|TUnlessNotEmptyReturnType */ public function unlessNotEmpty(callable $callback, ?callable $default = null): mixed @@ -882,6 +885,7 @@ public function chunkBy(callable|string $key): static * Pass the collection to the given callback and then return it. * * @param callable($this): mixed $callback + * @return $this */ public function tap(callable $callback): static { @@ -999,6 +1003,8 @@ public function __toString(): string /** * Indicate that the model's string representation should be escaped when __toString is invoked. + * + * @return $this */ public function escapeWhenCastingToString(bool $escape = true): static { diff --git a/src/concurrency/src/ConcurrencyManager.php b/src/concurrency/src/ConcurrencyManager.php index 73048752bc..77a952dc3f 100644 --- a/src/concurrency/src/ConcurrencyManager.php +++ b/src/concurrency/src/ConcurrencyManager.php @@ -4,6 +4,8 @@ namespace Hypervel\Concurrency; +use Carbon\CarbonInterval; +use Closure; use Hypervel\Contracts\Concurrency\Driver; use Hypervel\Process\Factory as ProcessFactory; use Hypervel\Support\MultipleInstanceManager; @@ -12,12 +14,16 @@ use function Hypervel\Support\enum_value; /** + * @method array run(Closure|array $tasks, CarbonInterval|int|null $timeout = null) + * * @mixin Driver */ class ConcurrencyManager extends MultipleInstanceManager { /** * Get a driver instance by name. + * + * @return Driver */ public function driver(UnitEnum|string|null $name = null): mixed { diff --git a/src/concurrency/src/ProcessDriver.php b/src/concurrency/src/ProcessDriver.php index 7d8c5b4b99..07eeb4cdf2 100644 --- a/src/concurrency/src/ProcessDriver.php +++ b/src/concurrency/src/ProcessDriver.php @@ -41,7 +41,6 @@ public function run(Closure|array $tasks, CarbonInterval|int|null $timeout = nul $results = $this->processFactory->pool(function (Pool $pool) use ($tasks, $command, $timeout) { foreach (Arr::wrap($tasks) as $key => $task) { $process = $pool->as((string) $key)->path(base_path())->env([ - /* @phpstan-ignore staticMethod.notFound */ '__HYPERVEL_CONTEXT' => base64_encode(serialize(Context::dehydrate())), 'HYPERVEL_INVOKABLE_CLOSURE' => base64_encode( serialize(new SerializableClosure($task)) @@ -73,7 +72,6 @@ public function defer(Closure|array $tasks): DeferredCallback return defer(function () use ($tasks, $command) { foreach (Arr::wrap($tasks) as $task) { $this->processFactory->path(base_path())->env([ - /* @phpstan-ignore staticMethod.notFound */ '__HYPERVEL_CONTEXT' => base64_encode(serialize(Context::dehydrate())), 'HYPERVEL_INVOKABLE_CLOSURE' => base64_encode( serialize(new SerializableClosure($task)) diff --git a/src/conditionable/src/HigherOrderWhenProxy.php b/src/conditionable/src/HigherOrderWhenProxy.php index ba43176f67..8b449066ac 100644 --- a/src/conditionable/src/HigherOrderWhenProxy.php +++ b/src/conditionable/src/HigherOrderWhenProxy.php @@ -4,6 +4,10 @@ namespace Hypervel\Support; +/** + * @template TTarget + * @template-covariant THasCondition of bool = false + */ class HigherOrderWhenProxy { /** @@ -23,6 +27,8 @@ class HigherOrderWhenProxy /** * Create a new proxy instance. + * + * @param TTarget $target */ public function __construct( protected mixed $target, @@ -31,16 +37,23 @@ public function __construct( /** * Set the condition on the proxy. + * + * @return static + * + * @phpstan-self-out static */ public function condition(mixed $condition): static { [$this->condition, $this->hasCondition] = [(bool) $condition, true]; + // @phpstan-ignore return.type (the proxy now has a condition) return $this; } /** * Indicate that the condition should be negated. + * + * @return $this */ public function negateConditionOnCapture(): static { diff --git a/src/conditionable/src/Traits/Conditionable.php b/src/conditionable/src/Traits/Conditionable.php index fca2722891..b50d810ae5 100644 --- a/src/conditionable/src/Traits/Conditionable.php +++ b/src/conditionable/src/Traits/Conditionable.php @@ -13,12 +13,12 @@ trait Conditionable * Apply the callback if the given "value" is (or resolves to) truthy. * * @template TWhenParameter - * @template TWhenReturnType + * @template TWhenReturnType = never * * @param null|(Closure($this): TWhenParameter)|TWhenParameter $value - * @param null|(callable($this, TWhenParameter): TWhenReturnType) $callback - * @param null|(callable($this, TWhenParameter): TWhenReturnType) $default - * @return $this|TWhenReturnType + * @param null|(callable($this, TWhenParameter): (null|TWhenReturnType|void)) $callback + * @param null|(callable($this, TWhenParameter): (null|TWhenReturnType|void)) $default + * @return ($callback is null ? ($default is null ? $this|HigherOrderWhenProxy<$this, bool>|TWhenReturnType : $this|TWhenReturnType) : $this|TWhenReturnType) */ public function when(mixed $value = null, ?callable $callback = null, ?callable $default = null): mixed { @@ -46,12 +46,12 @@ public function when(mixed $value = null, ?callable $callback = null, ?callable * Apply the callback if the given "value" is (or resolves to) falsy. * * @template TUnlessParameter - * @template TUnlessReturnType + * @template TUnlessReturnType = never * * @param null|(Closure($this): TUnlessParameter)|TUnlessParameter $value - * @param null|(callable($this, TUnlessParameter): TUnlessReturnType) $callback - * @param null|(callable($this, TUnlessParameter): TUnlessReturnType) $default - * @return $this|TUnlessReturnType + * @param null|(callable($this, TUnlessParameter): (null|TUnlessReturnType|void)) $callback + * @param null|(callable($this, TUnlessParameter): (null|TUnlessReturnType|void)) $default + * @return ($callback is null ? ($default is null ? $this|HigherOrderWhenProxy<$this, bool>|TUnlessReturnType : $this|TUnlessReturnType) : $this|TUnlessReturnType) */ public function unless(mixed $value = null, ?callable $callback = null, ?callable $default = null): mixed { diff --git a/src/connection-pool/src/Connection.php b/src/connection-pool/src/Connection.php index 6666fdb210..94b52bb03a 100644 --- a/src/connection-pool/src/Connection.php +++ b/src/connection-pool/src/Connection.php @@ -54,6 +54,7 @@ public function release(): void } // Preserve the listener or logger cancellation over secondary cleanup failures. + // @phpstan-ignore hypervel.cancellation.exactType (a direct cancellation is no cleanup failure; one carrying it is still logged) if (! $exception instanceof CanceledException) { try { $this->logger?->error((string) $exception); diff --git a/src/connection-pool/src/ConnectionPool.php b/src/connection-pool/src/ConnectionPool.php index e6defb8910..5d03321b28 100644 --- a/src/connection-pool/src/ConnectionPool.php +++ b/src/connection-pool/src/ConnectionPool.php @@ -179,16 +179,18 @@ public function checkIdleConnection(): void try { $healthy = $connection->check(); - } catch (CanceledException $cancellation) { - try { - $this->destroyConnection($connection); - } catch (CanceledException) { - } catch (Throwable $exception) { - $this->report($exception); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + try { + $this->destroyConnection($connection); + } catch (CanceledException) { + } catch (Throwable $cleanupException) { + $this->report($cleanupException); + } + + throw $exception; } - throw $cancellation; - } catch (Throwable $exception) { $this->report($exception); $healthy = false; } @@ -219,10 +221,12 @@ public function close(): void try { $this->idleMonitor?->stop(); - } catch (CanceledException $exception) { - $cancellation = $exception; } catch (Throwable $exception) { - $this->report($exception); + if (Coroutine::causedByCancellation($exception)) { + $cancellation = $exception; + } else { + $this->report($exception); + } } $this->channel->close(); @@ -235,7 +239,7 @@ public function close(): void throw $exception; } - $cancellation ??= $exception; + $cancellation = Coroutine::preferCancellation($cancellation, $exception); } } diff --git a/src/connection-pool/src/KeepaliveConnection.php b/src/connection-pool/src/KeepaliveConnection.php index f54519aa12..67b490e9e4 100644 --- a/src/connection-pool/src/KeepaliveConnection.php +++ b/src/connection-pool/src/KeepaliveConnection.php @@ -13,6 +13,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Log\StdoutLoggerInterface; use Hypervel\Coordinator\Timer; +use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; use Psr\Log\LoggerInterface; use Swoole\Coroutine\CanceledException; @@ -93,9 +94,11 @@ public function reconnect(): bool if ($this->isConnected()) { try { $this->sendClose($connection); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $message = sprintf('Socket of %s close failed, %s', $this->name, $exception); if ($logger = $this->getLogger()) { @@ -252,16 +255,15 @@ protected function addHeartbeat(): void } $this->heartbeat(); - } catch (CanceledException $exception) { + } catch (Throwable $throwable) { if ($this->channel === $channel) { $this->closeAfterFailure(); } - throw $exception; - } catch (Throwable $throwable) { - if ($this->channel === $channel) { - $this->closeAfterFailure(); + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; } + $message = sprintf('Socket of %s heartbeat failed, %s', $this->name, $throwable); if ($logger = $this->getLogger()) { diff --git a/src/console/src/CacheCommandMutex.php b/src/console/src/CacheCommandMutex.php index d13d2809d9..796a6d9954 100644 --- a/src/console/src/CacheCommandMutex.php +++ b/src/console/src/CacheCommandMutex.php @@ -107,6 +107,8 @@ protected function shouldUseLocks(Store $store): bool * * Boot-only. Mutates the shared command mutex instance for the worker * lifetime; per-request use races across coroutines. + * + * @return $this */ public function useStore(?string $store): static { diff --git a/src/console/src/Command.php b/src/console/src/Command.php index f8c792aac9..c277544d2b 100644 --- a/src/console/src/Command.php +++ b/src/console/src/Command.php @@ -325,18 +325,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int )); } } catch (Throwable $throwable) { - if ($exception === null || (Coroutine::causedByCancellation($throwable) && ! Coroutine::causedByCancellation($exception))) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($commandMutex !== null) { try { $commandMutex->forget($this); } catch (Throwable $throwable) { - if ($exception === null || (Coroutine::causedByCancellation($throwable) && ! Coroutine::causedByCancellation($exception))) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php index 2a2d0af2de..7363966fe8 100644 --- a/src/console/src/Commands/ScheduleRunCommand.php +++ b/src/console/src/Commands/ScheduleRunCommand.php @@ -8,6 +8,7 @@ use Closure; use Hypervel\Console\Command; use Hypervel\Console\Events\ScheduledBackgroundTaskFinished; +use Hypervel\Console\Events\ScheduledTaskCanceled; use Hypervel\Console\Events\ScheduledTaskFailed; use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Console\Events\ScheduledTaskSkipped; @@ -29,6 +30,7 @@ use Hypervel\Support\Defer\DeferredCallback; use Hypervel\Support\Defer\DeferredCallbackCollection; use Hypervel\Support\Facades\Date; +use Hypervel\Support\FailureReporter; use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Sleep; use RuntimeException; @@ -480,67 +482,108 @@ protected function runEvent(Event $event): void // Tasks can overlap, so write complete lines instead of leaving a task row open. $this->line($description); - if ($this->dispatcher->hasListeners(ScheduledTaskStarting::class)) { - $this->dispatcher->dispatch(new ScheduledTaskStarting($event)); - } + $runtime = null; + $failure = null; + $running = false; + $skippedBecauseOverlapping = false; - $start = microtime(true); + // Starting listeners can begin task telemetry, so every exit from here on publishes + // an outcome: cancellation publishes ScheduledTaskCanceled in place of the one it prevented. + try { + if ($this->dispatcher->hasListeners(ScheduledTaskStarting::class)) { + $this->dispatcher->dispatch(new ScheduledTaskStarting($event)); + } - $this->registerRunningEvent($event); + $start = microtime(true); - $failed = false; - $skippedBecauseOverlapping = false; - $runtime = null; + $this->registerRunningEvent($event); + $running = true; - try { - $event->run($this->hypervel); - $runtime = round(microtime(true) - $start, 2); - $skippedBecauseOverlapping = $event->wasSkippedDueToOverlapping(); + try { + $event->run($this->hypervel); + $runtime = round(microtime(true) - $start, 2); + $skippedBecauseOverlapping = $event->wasSkippedDueToOverlapping(); - if ($this->dispatcher->hasListeners(ScheduledTaskFinished::class)) { - $this->dispatcher->dispatch(new ScheduledTaskFinished($event, $runtime)); - } + if ($this->dispatcher->hasListeners(ScheduledTaskFinished::class)) { + $this->dispatcher->dispatch(new ScheduledTaskFinished($event, $runtime)); + } - $this->eventsRan = true; + $this->eventsRan = true; - $exitCode = $event->exitCode(); + $exitCode = $event->exitCode(); - if ( - ! $skippedBecauseOverlapping - && $exitCode !== null - && $exitCode !== 0 - ) { - throw new RuntimeException( - "Scheduled command [{$command}] failed with exit code [{$exitCode}]." - ); + if ( + ! $skippedBecauseOverlapping + && $exitCode !== null + && $exitCode !== 0 + ) { + throw new RuntimeException( + "Scheduled command [{$command}] failed with exit code [{$exitCode}]." + ); + } + } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + + $failure = $e; + // runEvent reports ordinary failures instead of throwing them to + // runTask; keep that outcome local to this task's deferred work. + CoroutineContext::set(self::TASK_FAILED_CONTEXT_KEY, true); + + if ($this->dispatcher->hasListeners(ScheduledTaskFailed::class)) { + // Unlike upstream, a run that fails after the task finished (a failing exit code or a throwing + // Finished listener) reports the task's own runtime, without the time its Finished listeners took. + $this->dispatcher->dispatch(new ScheduledTaskFailed( + $event, + $e, + $runtime ?? round(microtime(true) - $start, 2) + )); + } + + $this->handler->report($e); + } finally { + $this->forgetRunningEvent($event); } - } catch (Throwable $e) { - if (Coroutine::causedByCancellation($e)) { - throw $e; + } catch (Throwable $throwable) { + // A failure before the task ran still aborts the run, as upstream. + if (! $running && ! Coroutine::causedByCancellation($throwable)) { + $failure = $throwable; + + try { + if ($this->dispatcher->hasListeners(ScheduledTaskFailed::class)) { + $this->dispatcher->dispatch(new ScheduledTaskFailed($event, $throwable)); + } + } catch (Throwable $listenerFailure) { + if (Coroutine::causedByCancellation($listenerFailure)) { + $throwable = $listenerFailure; + } else { + FailureReporter::report($listenerFailure); + } + } } - $failed = true; - // runEvent reports ordinary failures instead of throwing them to - // runTask; keep that outcome local to this task's deferred work. - CoroutineContext::set(self::TASK_FAILED_CONTEXT_KEY, true); - - if ($this->dispatcher->hasListeners(ScheduledTaskFailed::class)) { - // Unlike upstream, a run that fails after the task finished (a failing exit code or a throwing - // Finished listener) reports the task's own runtime, without the time its Finished listeners took. - $this->dispatcher->dispatch(new ScheduledTaskFailed( - $event, - $e, - $runtime ?? round(microtime(true) - $start, 2) - )); + if (Coroutine::causedByCancellation($throwable)) { + try { + if ($this->dispatcher->hasListeners(ScheduledTaskCanceled::class)) { + $this->dispatcher->dispatch(new ScheduledTaskCanceled( + $event, + $throwable, + $runtime, + $failure, + )); + } + } catch (Throwable $listenerFailure) { + // The cancellation keeps precedence; the listener failure is still reported. + FailureReporter::report($listenerFailure); + } } - $this->handler->report($e); - } finally { - $this->forgetRunningEvent($event); + throw $throwable; } $status = match (true) { - $failed => 'Failed', + $failure !== null => 'Failed', $skippedBecauseOverlapping => 'Skipped', $event->exitCode() === 0 => 'Finished', default => 'Failed', diff --git a/src/console/src/Concerns/InteractsWithIO.php b/src/console/src/Concerns/InteractsWithIO.php index 55fc3ce60b..36c9f7a121 100644 --- a/src/console/src/Concerns/InteractsWithIO.php +++ b/src/console/src/Concerns/InteractsWithIO.php @@ -328,6 +328,8 @@ public function alert(string $string, int|string|null $verbosity = null): void /** * Write a blank line. + * + * @return $this */ public function newLine(int $count = 1): static { diff --git a/src/console/src/Concerns/PromptsForMissingInput.php b/src/console/src/Concerns/PromptsForMissingInput.php index b25938e5c6..c5dd8ae917 100644 --- a/src/console/src/Concerns/PromptsForMissingInput.php +++ b/src/console/src/Concerns/PromptsForMissingInput.php @@ -44,7 +44,7 @@ protected function promptForMissingArguments(InputInterface $input, OutputInterf ?? 'What is ' . lcfirst($argument->getDescription() ?: ('the ' . $argument->getName())) . '?'; if ($label instanceof Closure) { - return $input->setArgument($argument->getName(), $argument->isArray() ? Arr::wrap($label()) : $label()); // @phpstan-ignore method.void + return $input->setArgument($argument->getName(), $argument->isArray() ? Arr::wrap($label()) : $label()); // @phpstan-ignore method.void (returns the void call's null, as upstream) } if (is_array($label)) { diff --git a/src/console/src/ConsoleServiceProvider.php b/src/console/src/ConsoleServiceProvider.php index 75c0c357dd..6b782dc702 100644 --- a/src/console/src/ConsoleServiceProvider.php +++ b/src/console/src/ConsoleServiceProvider.php @@ -42,6 +42,7 @@ public function register(): void public function boot(Dispatcher $events): void { if (! $this->app->runningInConsole() + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parent process passes its context to child commands through the environment) || ($encoded = Env::get('__HYPERVEL_CONTEXT')) === null) { return; } diff --git a/src/console/src/Events/ScheduledTaskCanceled.php b/src/console/src/Events/ScheduledTaskCanceled.php new file mode 100644 index 0000000000..4a28845020 --- /dev/null +++ b/src/console/src/Events/ScheduledTaskCanceled.php @@ -0,0 +1,30 @@ +addTestOptions(); // @phpstan-ignore method.notFound + $this->addTestOptions(); // @phpstan-ignore method.notFound (provided by CreatesMatchingTest, checked above) } $this->addPathAndNamespaceOptions(); @@ -214,7 +214,7 @@ public function handle(): bool|int $info = $this->type; if (isset(class_uses_recursive($this)[CreatesMatchingTest::class])) { - $this->handleTestCreation($path); // @phpstan-ignore method.notFound + $this->handleTestCreation($path); // @phpstan-ignore method.notFound (provided by CreatesMatchingTest, checked above) } if (windows_os()) { @@ -376,6 +376,8 @@ protected function buildClass(string $name): string /** * Replace the namespace for the given stub. + * + * @return $this */ protected function replaceNamespace(string &$stub, string $name): static { diff --git a/src/console/src/Scheduling/CacheAware.php b/src/console/src/Scheduling/CacheAware.php index 742bd78907..986c21f81b 100644 --- a/src/console/src/Scheduling/CacheAware.php +++ b/src/console/src/Scheduling/CacheAware.php @@ -11,6 +11,8 @@ interface CacheAware * * Boot-only. Mutates the shared mutex instance for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function useStore(string $store): static; } diff --git a/src/console/src/Scheduling/CacheEventMutex.php b/src/console/src/Scheduling/CacheEventMutex.php index e3fb8bc54c..a1f9a4e1a2 100644 --- a/src/console/src/Scheduling/CacheEventMutex.php +++ b/src/console/src/Scheduling/CacheEventMutex.php @@ -97,6 +97,8 @@ protected function shouldUseLocks(Store $store): bool * * Boot-only. Mutates the shared mutex instance for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function useStore(?string $store): static { diff --git a/src/console/src/Scheduling/CacheSchedulingMutex.php b/src/console/src/Scheduling/CacheSchedulingMutex.php index d9c1529a24..68e9abf5cf 100644 --- a/src/console/src/Scheduling/CacheSchedulingMutex.php +++ b/src/console/src/Scheduling/CacheSchedulingMutex.php @@ -83,6 +83,8 @@ protected function shouldUseLocks(Store $store): bool * * Boot-only. Mutates the shared mutex instance for the worker lifetime; * per-request use races across coroutines. + * + * @return $this */ public function useStore(?string $store): static { diff --git a/src/console/src/Scheduling/CallbackEvent.php b/src/console/src/Scheduling/CallbackEvent.php index 0a398cef22..2ed6a5e13b 100644 --- a/src/console/src/Scheduling/CallbackEvent.php +++ b/src/console/src/Scheduling/CallbackEvent.php @@ -151,6 +151,8 @@ protected function execute(Container $container): int * * The expiration time of the underlying cache lock may be specified in minutes. * + * @return $this + * * @throws LogicException */ public function withoutOverlapping(int $expiresAt = 1440, bool $releaseOnTerminationSignals = true): static @@ -167,6 +169,8 @@ public function withoutOverlapping(int $expiresAt = 1440, bool $releaseOnTermina /** * Allow the event to only run on one server for each cron expression. * + * @return $this + * * @throws LogicException */ public function onOneServer(): static diff --git a/src/console/src/Scheduling/Event.php b/src/console/src/Scheduling/Event.php index dbf9545da9..254f7259d6 100644 --- a/src/console/src/Scheduling/Event.php +++ b/src/console/src/Scheduling/Event.php @@ -194,9 +194,7 @@ public function run(Container $container): mixed try { $this->finish($container, $exitCode); } catch (Throwable $throwable) { - if ($exception === null || (Coroutine::causedByCancellation($throwable) && ! Coroutine::causedByCancellation($exception))) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -315,17 +313,13 @@ public function finish(Container $container, int $exitCode): void try { $this->callAfterCallbacks($container); } catch (Throwable $throwable) { - if ($exception === null || (Coroutine::causedByCancellation($throwable) && ! Coroutine::causedByCancellation($exception))) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } try { $this->removeMutex(); } catch (Throwable $throwable) { - if ($exception === null || (Coroutine::causedByCancellation($throwable) && ! Coroutine::causedByCancellation($exception))) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } CoroutineContext::forget($this->processContextKey()); @@ -443,6 +437,8 @@ public function filtersPass(ApplicationContract $app): bool /** * Ensure that the output is stored on disk in a log file. + * + * @return $this */ public function storeOutput(): static { @@ -453,6 +449,8 @@ public function storeOutput(): static /** * Send the output of the command to a given location. + * + * @return $this */ public function sendOutputTo(string $location, bool $append = false): static { @@ -500,6 +498,8 @@ public function getOutput(Container $container): ?string /** * Append the output of the command to a given location. + * + * @return $this */ public function appendOutputTo(string $location): static { @@ -509,6 +509,8 @@ public function appendOutputTo(string $location): static /** * E-mail the results of the scheduled operation. * + * @return $this + * * @throws LogicException */ public function emailOutputTo(mixed $addresses, bool $onlyIfOutputExists = true): static @@ -525,6 +527,8 @@ public function emailOutputTo(mixed $addresses, bool $onlyIfOutputExists = true) /** * E-mail the results of the scheduled operation if it produces output. * + * @return $this + * * @throws LogicException */ public function emailWrittenOutputTo(mixed $addresses): static @@ -534,6 +538,8 @@ public function emailWrittenOutputTo(mixed $addresses): static /** * E-mail the results of the scheduled operation if it fails. + * + * @return $this */ public function emailOutputOnFailure(mixed $addresses): static { @@ -591,6 +597,8 @@ protected function getEmailSubject(): string /** * Register a callback to ping a given URL before the job runs. + * + * @return $this */ public function pingBefore(string $url): static { @@ -599,6 +607,8 @@ public function pingBefore(string $url): static /** * Register a callback to ping a given URL before the job runs if the given condition is true. + * + * @return $this */ public function pingBeforeIf(bool $value, string $url): static { @@ -607,6 +617,8 @@ public function pingBeforeIf(bool $value, string $url): static /** * Register a callback to ping a given URL after the job runs. + * + * @return $this */ public function thenPing(string $url): static { @@ -615,6 +627,8 @@ public function thenPing(string $url): static /** * Register a callback to ping a given URL after the job runs if the given condition is true. + * + * @return $this */ public function thenPingIf(bool $value, string $url): static { @@ -623,6 +637,8 @@ public function thenPingIf(bool $value, string $url): static /** * Register a callback to ping a given URL if the operation succeeds. + * + * @return $this */ public function pingOnSuccess(string $url): static { @@ -631,6 +647,8 @@ public function pingOnSuccess(string $url): static /** * Register a callback to ping a given URL if the operation succeeds and if the given condition is true. + * + * @return $this */ public function pingOnSuccessIf(bool $value, string $url): static { @@ -639,6 +657,8 @@ public function pingOnSuccessIf(bool $value, string $url): static /** * Register a callback to ping a given URL if the operation fails. + * + * @return $this */ public function pingOnFailure(string $url): static { @@ -647,6 +667,8 @@ public function pingOnFailure(string $url): static /** * Register a callback to ping a given URL if the operation fails and if the given condition is true. + * + * @return $this */ public function pingOnFailureIf(bool $value, string $url): static { @@ -685,6 +707,8 @@ protected function getHttpClient(Container $container): HttpClientInterface /** * Register a callback to be called before the operation. + * + * @return $this */ public function before(Closure $callback): static { @@ -695,6 +719,8 @@ public function before(Closure $callback): static /** * Register a callback to be called after the operation. + * + * @return $this */ public function after(Closure $callback): static { @@ -703,6 +729,8 @@ public function after(Closure $callback): static /** * Register a callback to be called after the operation. + * + * @return $this */ public function then(Closure $callback): static { @@ -719,6 +747,8 @@ public function then(Closure $callback): static /** * Register a callback that uses the output after the job runs. + * + * @return $this */ public function thenWithOutput(Closure $callback, bool $onlyIfOutputExists = false): static { @@ -729,6 +759,8 @@ public function thenWithOutput(Closure $callback, bool $onlyIfOutputExists = fal /** * Register a callback to be called if the operation succeeds. + * + * @return $this */ public function onSuccess(Closure $callback): static { @@ -747,6 +779,8 @@ public function onSuccess(Closure $callback): static /** * Register a callback that uses the output if the operation succeeds. + * + * @return $this */ public function onSuccessWithOutput(Closure $callback, bool $onlyIfOutputExists = false): static { @@ -757,6 +791,8 @@ public function onSuccessWithOutput(Closure $callback, bool $onlyIfOutputExists /** * Register a callback to be called if the operation fails. + * + * @return $this */ public function onFailure(Closure $callback): static { @@ -775,6 +811,8 @@ public function onFailure(Closure $callback): static /** * Register a callback that uses the output if the operation fails. + * + * @return $this */ public function onFailureWithOutput(Closure $callback, bool $onlyIfOutputExists = false): static { @@ -875,6 +913,8 @@ public function getExpression(): string /** * Set the event mutex implementation to be used. + * + * @return $this */ public function preventOverlapsUsing(EventMutex $mutex): static { @@ -900,6 +940,8 @@ public function mutexName(): string /** * Set the mutex name or name resolver callback. + * + * @return $this */ public function createMutexNameUsing(Closure|string $mutexName): static { diff --git a/src/console/src/Scheduling/ManagesAttributes.php b/src/console/src/Scheduling/ManagesAttributes.php index a5a6f3f9fc..ff3e32a2c8 100644 --- a/src/console/src/Scheduling/ManagesAttributes.php +++ b/src/console/src/Scheduling/ManagesAttributes.php @@ -91,6 +91,8 @@ trait ManagesAttributes /** * Limit the environments the command should run in. + * + * @return $this */ public function environments(UnitEnum|array|string $environments): static { @@ -104,6 +106,8 @@ enum_value(...), /** * State that the command should run even in maintenance mode. + * + * @return $this */ public function evenInMaintenanceMode(): static { @@ -114,6 +118,8 @@ public function evenInMaintenanceMode(): static /** * State that the command should run even when the scheduler is paused. + * + * @return $this */ public function evenWhenPaused(): static { @@ -126,6 +132,8 @@ public function evenWhenPaused(): static * Do not allow the event to overlap each other. * * The expiration time of the underlying cache lock may be specified in minutes. + * + * @return $this */ public function withoutOverlapping(int $expiresAt = 1440, bool $releaseOnTerminationSignals = true): static { @@ -142,6 +150,8 @@ public function withoutOverlapping(int $expiresAt = 1440, bool $releaseOnTermina /** * Allow the event to only run on one server for each cron expression. + * + * @return $this */ public function onOneServer(): static { @@ -152,6 +162,8 @@ public function onOneServer(): static /** * State that the command should run in the background. + * + * @return $this */ public function runInBackground(): static { @@ -162,6 +174,8 @@ public function runInBackground(): static /** * Register a callback to further filter the schedule. + * + * @return $this */ public function when(bool|callable $callback): static { @@ -174,6 +188,8 @@ public function when(bool|callable $callback): static /** * Register a callback to further filter the schedule. + * + * @return $this */ public function skip(bool|callable $callback): static { @@ -186,6 +202,8 @@ public function skip(bool|callable $callback): static /** * Set the human-friendly description of the event. + * + * @return $this */ public function name(string $description): static { @@ -194,6 +212,8 @@ public function name(string $description): static /** * Set the human-friendly description of the event. + * + * @return $this */ public function description(string $description): static { @@ -204,6 +224,8 @@ public function description(string $description): static /** * Set arbitrary attributes to store with the event. + * + * @return $this */ public function withAttributes(array $attributes): static { diff --git a/src/console/src/Scheduling/ManagesFrequencies.php b/src/console/src/Scheduling/ManagesFrequencies.php index db2e9535a7..622efed268 100644 --- a/src/console/src/Scheduling/ManagesFrequencies.php +++ b/src/console/src/Scheduling/ManagesFrequencies.php @@ -16,6 +16,8 @@ trait ManagesFrequencies { /** * The Cron expression representing the event's frequency. + * + * @return $this */ public function cron(string $expression): static { @@ -26,6 +28,8 @@ public function cron(string $expression): static /** * Schedule the event to run between start and end time. + * + * @return $this */ public function between(string $startTime, string $endTime): static { @@ -34,6 +38,8 @@ public function between(string $startTime, string $endTime): static /** * Schedule the event to not run between start and end time. + * + * @return $this */ public function unlessBetween(string $startTime, string $endTime): static { @@ -66,6 +72,8 @@ private function inTimeInterval(string $startTime, string $endTime): Closure /** * Schedule the event to run every second. + * + * @return $this */ public function everySecond(): static { @@ -74,6 +82,8 @@ public function everySecond(): static /** * Schedule the event to run every two seconds. + * + * @return $this */ public function everyTwoSeconds(): static { @@ -82,6 +92,8 @@ public function everyTwoSeconds(): static /** * Schedule the event to run every five seconds. + * + * @return $this */ public function everyFiveSeconds(): static { @@ -90,6 +102,8 @@ public function everyFiveSeconds(): static /** * Schedule the event to run every ten seconds. + * + * @return $this */ public function everyTenSeconds(): static { @@ -98,6 +112,8 @@ public function everyTenSeconds(): static /** * Schedule the event to run every fifteen seconds. + * + * @return $this */ public function everyFifteenSeconds(): static { @@ -106,6 +122,8 @@ public function everyFifteenSeconds(): static /** * Schedule the event to run every twenty seconds. + * + * @return $this */ public function everyTwentySeconds(): static { @@ -114,6 +132,8 @@ public function everyTwentySeconds(): static /** * Schedule the event to run every thirty seconds. + * + * @return $this */ public function everyThirtySeconds(): static { @@ -124,6 +144,7 @@ public function everyThirtySeconds(): static * Schedule the event to run multiple times per minute. * * @param int<1, 59> $seconds + * @return $this * * @throws InvalidArgumentException */ @@ -144,6 +165,8 @@ protected function repeatEvery(int $seconds): static /** * Schedule the event to run every minute. + * + * @return $this */ public function everyMinute(): static { @@ -152,6 +175,8 @@ public function everyMinute(): static /** * Schedule the event to run every two minutes. + * + * @return $this */ public function everyTwoMinutes(): static { @@ -160,6 +185,8 @@ public function everyTwoMinutes(): static /** * Schedule the event to run every three minutes. + * + * @return $this */ public function everyThreeMinutes(): static { @@ -168,6 +195,8 @@ public function everyThreeMinutes(): static /** * Schedule the event to run every four minutes. + * + * @return $this */ public function everyFourMinutes(): static { @@ -176,6 +205,8 @@ public function everyFourMinutes(): static /** * Schedule the event to run every five minutes. + * + * @return $this */ public function everyFiveMinutes(): static { @@ -184,6 +215,8 @@ public function everyFiveMinutes(): static /** * Schedule the event to run every ten minutes. + * + * @return $this */ public function everyTenMinutes(): static { @@ -192,6 +225,8 @@ public function everyTenMinutes(): static /** * Schedule the event to run every fifteen minutes. + * + * @return $this */ public function everyFifteenMinutes(): static { @@ -200,6 +235,8 @@ public function everyFifteenMinutes(): static /** * Schedule the event to run every thirty minutes. + * + * @return $this */ public function everyThirtyMinutes(): static { @@ -208,6 +245,8 @@ public function everyThirtyMinutes(): static /** * Schedule the event to run hourly. + * + * @return $this */ public function hourly(): static { @@ -218,6 +257,7 @@ public function hourly(): static * Schedule the event to run hourly at a given offset in the hour. * * @param array|int<0, 59>|int<0, 59>[]|string $offset + * @return $this */ public function hourlyAt(array|int|string $offset): static { @@ -226,6 +266,8 @@ public function hourlyAt(array|int|string $offset): static /** * Schedule the event to run every odd hour. + * + * @return $this */ public function everyOddHour(array|int|string $offset = 0): static { @@ -234,6 +276,8 @@ public function everyOddHour(array|int|string $offset = 0): static /** * Schedule the event to run every two hours. + * + * @return $this */ public function everyTwoHours(array|int|string $offset = 0): static { @@ -242,6 +286,8 @@ public function everyTwoHours(array|int|string $offset = 0): static /** * Schedule the event to run every three hours. + * + * @return $this */ public function everyThreeHours(array|int|string $offset = 0): static { @@ -250,6 +296,8 @@ public function everyThreeHours(array|int|string $offset = 0): static /** * Schedule the event to run every four hours. + * + * @return $this */ public function everyFourHours(array|int|string $offset = 0): static { @@ -258,6 +306,8 @@ public function everyFourHours(array|int|string $offset = 0): static /** * Schedule the event to run every six hours. + * + * @return $this */ public function everySixHours(array|int|string $offset = 0): static { @@ -266,6 +316,8 @@ public function everySixHours(array|int|string $offset = 0): static /** * Schedule the event to run daily. + * + * @return $this */ public function daily(): static { @@ -274,6 +326,8 @@ public function daily(): static /** * Schedule the command at a given time. + * + * @return $this */ public function at(string $time): static { @@ -282,6 +336,8 @@ public function at(string $time): static /** * Schedule the event to run daily at a given time (10:00, 19:30, etc). + * + * @return $this */ public function dailyAt(string $time): static { @@ -298,6 +354,7 @@ public function dailyAt(string $time): static * * @param int<0, 23> $first * @param int<0, 23> $second + * @return $this */ public function twiceDaily(int $first = 1, int $second = 13): static { @@ -310,6 +367,7 @@ public function twiceDaily(int $first = 1, int $second = 13): static * @param int<0, 23> $first * @param int<0, 23> $second * @param int<0, 59> $offset + * @return $this */ public function twiceDailyAt(int $first = 1, int $second = 13, int $offset = 0): static { @@ -323,6 +381,7 @@ public function twiceDailyAt(int $first = 1, int $second = 13, int $offset = 0): * * @param array|int<0, 59>|string $minutes * @param array|int<0, 23>|string $hours + * @return $this */ protected function hourBasedSchedule(array|int|string $minutes, array|int|string $hours): static { @@ -336,6 +395,8 @@ protected function hourBasedSchedule(array|int|string $minutes, array|int|string /** * Schedule the event to run only on weekdays. + * + * @return $this */ public function weekdays(): static { @@ -344,6 +405,8 @@ public function weekdays(): static /** * Schedule the event to run only on weekends. + * + * @return $this */ public function weekends(): static { @@ -352,6 +415,8 @@ public function weekends(): static /** * Schedule the event to run only on Mondays. + * + * @return $this */ public function mondays(): static { @@ -360,6 +425,8 @@ public function mondays(): static /** * Schedule the event to run only on Tuesdays. + * + * @return $this */ public function tuesdays(): static { @@ -368,6 +435,8 @@ public function tuesdays(): static /** * Schedule the event to run only on Wednesdays. + * + * @return $this */ public function wednesdays(): static { @@ -376,6 +445,8 @@ public function wednesdays(): static /** * Schedule the event to run only on Thursdays. + * + * @return $this */ public function thursdays(): static { @@ -384,6 +455,8 @@ public function thursdays(): static /** * Schedule the event to run only on Fridays. + * + * @return $this */ public function fridays(): static { @@ -392,6 +465,8 @@ public function fridays(): static /** * Schedule the event to run only on Saturdays. + * + * @return $this */ public function saturdays(): static { @@ -400,6 +475,8 @@ public function saturdays(): static /** * Schedule the event to run only on Sundays. + * + * @return $this */ public function sundays(): static { @@ -408,6 +485,8 @@ public function sundays(): static /** * Schedule the event to run weekly. + * + * @return $this */ public function weekly(): static { @@ -418,6 +497,8 @@ public function weekly(): static /** * Schedule the event to run weekly on a given day and time. + * + * @return $this */ public function weeklyOn(mixed $dayOfWeek, string $time = '0:0'): static { @@ -428,6 +509,8 @@ public function weeklyOn(mixed $dayOfWeek, string $time = '0:0'): static /** * Schedule the event to run monthly. + * + * @return $this */ public function monthly(): static { @@ -440,6 +523,7 @@ public function monthly(): static * Schedule the event to run monthly on a given day and time. * * @param int<1, 31> $dayOfMonth + * @return $this */ public function monthlyOn(int $dayOfMonth = 1, string $time = '0:0'): static { @@ -453,6 +537,7 @@ public function monthlyOn(int $dayOfMonth = 1, string $time = '0:0'): static * * @param int<1, 31> $first * @param int<1, 31> $second + * @return $this */ public function twiceMonthly(int $first = 1, int $second = 16, string $time = '0:0'): static { @@ -465,6 +550,8 @@ public function twiceMonthly(int $first = 1, int $second = 16, string $time = '0 /** * Schedule the event to run on the last day of the month. + * + * @return $this */ public function lastDayOfMonth(string $time = '0:0'): static { @@ -477,6 +564,7 @@ public function lastDayOfMonth(string $time = '0:0'): static * Schedule the event to run on specific days of the month. * * @param array>|int<1, 31> ...$days + * @return $this */ public function daysOfMonth(int|array ...$days): static { @@ -490,6 +578,8 @@ public function daysOfMonth(int|array ...$days): static /** * Schedule the event to run quarterly. + * + * @return $this */ public function quarterly(): static { @@ -501,6 +591,8 @@ public function quarterly(): static /** * Schedule the event to run quarterly on a given day and time. + * + * @return $this */ public function quarterlyOn(int $dayOfQuarter = 1, string $time = '0:0'): static { @@ -512,6 +604,8 @@ public function quarterlyOn(int $dayOfQuarter = 1, string $time = '0:0'): static /** * Schedule the event to run yearly. + * + * @return $this */ public function yearly(): static { @@ -523,6 +617,8 @@ public function yearly(): static /** * Schedule the event to run yearly on a given month, day, and time. + * + * @return $this */ public function yearlyOn(int $month = 1, int|string $dayOfMonth = 1, string $time = '0:0'): static { @@ -534,6 +630,8 @@ public function yearlyOn(int $month = 1, int|string $dayOfMonth = 1, string $tim /** * Set the days of the week the command should run on. + * + * @return $this */ public function days(mixed $days): static { @@ -544,6 +642,8 @@ public function days(mixed $days): static /** * Set the timezone the date should be evaluated on. + * + * @return $this */ public function timezone(DateTimeZone|UnitEnum|string $timezone): static { @@ -556,6 +656,8 @@ public function timezone(DateTimeZone|UnitEnum|string $timezone): static /** * Splice the given value into the given position of the expression. + * + * @return $this */ protected function spliceIntoPosition(int $position, int|string $value): static { diff --git a/src/console/src/Scheduling/PendingEventAttributes.php b/src/console/src/Scheduling/PendingEventAttributes.php index f568eb73c3..3e83a91139 100644 --- a/src/console/src/Scheduling/PendingEventAttributes.php +++ b/src/console/src/Scheduling/PendingEventAttributes.php @@ -60,6 +60,8 @@ public function __construct( * Do not allow the event to overlap each other. * * The expiration time of the underlying cache lock may be specified in minutes. + * + * @return $this */ public function withoutOverlapping(int $expiresAt = 1440, bool $releaseOnTerminationSignals = true): static { diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php index 9a995b59a1..ab3985307c 100644 --- a/src/console/src/Scheduling/Schedule.php +++ b/src/console/src/Scheduling/Schedule.php @@ -217,7 +217,7 @@ function () use ($job, $queue, $connection) { : $job; if ($job instanceof ShouldQueue) { - $this->dispatchToQueue($job, $queue ?? $job->queue, $connection ?? $job->connection); // @phpstan-ignore property.notFound, property.notFound + $this->dispatchToQueue($job, $queue ?? $job->queue, $connection ?? $job->connection); // @phpstan-ignore property.notFound (Queueable property), property.notFound (Queueable property) } else { $this->dispatchNow($job); } @@ -455,6 +455,8 @@ public function eventsForEnvironments(array $environments): array * * Boot-only. Mutates the shared mutex instances used by this worker's * schedule; per-request use races across coroutines. + * + * @return $this */ public function useCache(UnitEnum|string|null $store): static { diff --git a/src/container/src/Container.php b/src/container/src/Container.php index fdfd342156..ac9f4955dc 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -19,6 +19,7 @@ use Hypervel\Contracts\Container\ExecutionScopedAttribute; use Hypervel\Contracts\Container\SelfBuilding; use Hypervel\Contracts\Container\Transient; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\Traits\ReflectsClosures; use InvalidArgumentException; @@ -29,7 +30,6 @@ use ReflectionFunction; use ReflectionParameter; use Swoole\Coroutine as SwooleCoroutine; -use Swoole\Coroutine\CanceledException; use Throwable; use TypeError; @@ -1127,8 +1127,7 @@ public function get(string $id): mixed try { return $this->resolve($id); } catch (Exception $e) { - // Cancellation is injected asynchronously, so a dedicated catch analyzes as unreachable here. - if ($e instanceof CanceledException) { + if (Coroutine::causedByCancellation($e)) { throw $e; } @@ -1961,7 +1960,7 @@ protected function buildSelfBuildingInstance( $resolutionState->buildStack[] = $concrete; try { - $instance = $this->call([$concrete, 'newInstance']); // @phpstan-ignore argument.type + $instance = $this->call([$concrete, 'newInstance']); // @phpstan-ignore argument.type (a class name and static method name form a callable) } finally { array_pop($resolutionState->buildStack); } diff --git a/src/container/src/ContextualBindingBuilder.php b/src/container/src/ContextualBindingBuilder.php index de1093f34a..1ad384c691 100644 --- a/src/container/src/ContextualBindingBuilder.php +++ b/src/container/src/ContextualBindingBuilder.php @@ -25,6 +25,8 @@ public function __construct( /** * Define the abstract target that depends on the context. + * + * @return $this */ public function needs(string $abstract): static { @@ -35,6 +37,8 @@ public function needs(string $abstract): static /** * Define the implementation for the contextual binding. + * + * @return $this */ public function give(mixed $implementation): static { @@ -47,6 +51,8 @@ public function give(mixed $implementation): static /** * Define tagged services to be used as the implementation for the contextual binding. + * + * @return $this */ public function giveTagged(string $tag): static { @@ -59,6 +65,8 @@ public function giveTagged(string $tag): static /** * Specify the configuration item to bind as a primitive. + * + * @return $this */ public function giveConfig(string $key, mixed $default = null): static { diff --git a/src/contracts/composer.json b/src/contracts/composer.json index 6bde739853..626c0b5230 100644 --- a/src/contracts/composer.json +++ b/src/contracts/composer.json @@ -32,8 +32,8 @@ "psr/log": "^3.0", "psr/simple-cache": "^3.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/contracts/src/Auth/Access/Gate.php b/src/contracts/src/Auth/Access/Gate.php index e5d27975ee..10e3261eba 100644 --- a/src/contracts/src/Auth/Access/Gate.php +++ b/src/contracts/src/Auth/Access/Gate.php @@ -25,27 +25,37 @@ public function has(array|UnitEnum|string $ability): bool; /** * Define a new ability. * + * @return $this + * * @throws InvalidArgumentException */ public function define(UnitEnum|string $ability, array|callable|string $callback): static; /** * Define abilities for a resource. + * + * @return $this */ public function resource(string $name, string $class, ?array $abilities = null): static; /** * Define a policy class for a given class type. + * + * @return $this */ public function policy(string $class, string $policy): static; /** * Register a callback to run before all Gate checks. + * + * @return $this */ public function before(callable $callback): static; /** * Register a callback to run after all Gate checks. + * + * @return $this */ public function after(callable $callback): static; diff --git a/src/contracts/src/Auth/Guard.php b/src/contracts/src/Auth/Guard.php index 9261697b93..06b675b28f 100644 --- a/src/contracts/src/Auth/Guard.php +++ b/src/contracts/src/Auth/Guard.php @@ -50,6 +50,8 @@ public function hasUser(): bool; /** * Set the current user. + * + * @return $this */ public function setUser(Authenticatable $user): static; } diff --git a/src/contracts/src/Bus/Dispatcher.php b/src/contracts/src/Bus/Dispatcher.php index e6e1423235..ddbc9769fd 100644 --- a/src/contracts/src/Bus/Dispatcher.php +++ b/src/contracts/src/Bus/Dispatcher.php @@ -45,11 +45,15 @@ public function getCommandHandler(mixed $command): mixed; /** * Set the pipes commands should be piped through before dispatching. + * + * @return $this */ public function pipeThrough(array $pipes): static; /** * Map a command to a handler. + * + * @return $this */ public function map(array $map): static; } diff --git a/src/contracts/src/Console/Application.php b/src/contracts/src/Console/Application.php index 08f0cc590d..22fd614e99 100644 --- a/src/contracts/src/Console/Application.php +++ b/src/contracts/src/Console/Application.php @@ -37,6 +37,8 @@ public function has(string $name): bool; /** * Get all of the commands registered with the application. + * + * @return array */ public function all(?string $namespace = null): array; diff --git a/src/contracts/src/Console/Kernel.php b/src/contracts/src/Console/Kernel.php index 244aa62c46..113d5b0128 100644 --- a/src/contracts/src/Console/Kernel.php +++ b/src/contracts/src/Console/Kernel.php @@ -33,6 +33,8 @@ public function resolveConsoleSchedule(): Schedule; /** * Register a Closure based command with the application. + * + * @param-closure-this ClosureCommand $callback */ public function command(string $signature, Closure $callback): ClosureCommand; @@ -77,6 +79,8 @@ public function queue(string $command, array $parameters = []): PendingDispatch; /** * Get all of the commands registered with the console. + * + * @return array */ public function all(): array; diff --git a/src/contracts/src/Container/ContextualBindingBuilder.php b/src/contracts/src/Container/ContextualBindingBuilder.php index 7cee39cdbf..53cb7154c8 100644 --- a/src/contracts/src/Container/ContextualBindingBuilder.php +++ b/src/contracts/src/Container/ContextualBindingBuilder.php @@ -8,21 +8,29 @@ interface ContextualBindingBuilder { /** * Define the abstract target that depends on the context. + * + * @return $this */ public function needs(string $abstract): static; /** * Define the implementation for the contextual binding. + * + * @return $this */ public function give(mixed $implementation): static; /** * Define tagged services to be used as the implementation for the contextual binding. + * + * @return $this */ public function giveTagged(string $tag): static; /** * Specify the configuration item to bind as a primitive. + * + * @return $this */ public function giveConfig(string $key, mixed $default = null): static; } diff --git a/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php b/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php index 09a5696083..c29e76dd89 100644 --- a/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php +++ b/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php @@ -11,6 +11,8 @@ interface SupportsPartialRelations { /** * Indicate that the relation is a single result of a larger one-to-many relationship. + * + * @return $this */ public function ofMany(array|string|null $column = 'id', string|Closure|null $aggregate = 'MAX', ?string $relation = null): static; diff --git a/src/contracts/src/Database/ModelIdentifier.php b/src/contracts/src/Database/ModelIdentifier.php index 3cfcad86c5..7c6385349f 100644 --- a/src/contracts/src/Database/ModelIdentifier.php +++ b/src/contracts/src/Database/ModelIdentifier.php @@ -77,6 +77,7 @@ public function __construct(?string $class, mixed $id, array $relations, ?string * Specify the collection class that should be used when serializing / restoring collections. * * @param null|class-string $collectionClass + * @return $this */ public function useCollectionClass(?string $collectionClass): static { diff --git a/src/contracts/src/Events/Dispatcher.php b/src/contracts/src/Events/Dispatcher.php index 6acab6e787..5106076fe4 100644 --- a/src/contracts/src/Events/Dispatcher.php +++ b/src/contracts/src/Events/Dispatcher.php @@ -41,6 +41,8 @@ public function until(object|string $event, mixed $payload = []): mixed; /** * Dispatch an event and call the listeners. + * + * @return ($halt is true ? mixed : null|array) */ public function dispatch(object|string $event, mixed $payload = [], bool $halt = false): mixed; diff --git a/src/contracts/src/Pipeline/Pipeline.php b/src/contracts/src/Pipeline/Pipeline.php index 08b426eea9..eb96bf2c02 100644 --- a/src/contracts/src/Pipeline/Pipeline.php +++ b/src/contracts/src/Pipeline/Pipeline.php @@ -10,16 +10,22 @@ interface Pipeline { /** * Set the object being sent through the pipeline. + * + * @return $this */ public function send(mixed $passable): static; /** * Set the array of pipes. + * + * @return $this */ public function through(mixed $pipes): static; /** * Set the method to call on the pipes. + * + * @return $this */ public function via(string $method): static; diff --git a/src/contracts/src/Process/InvokedProcess.php b/src/contracts/src/Process/InvokedProcess.php index 0519465e0a..034d484aae 100644 --- a/src/contracts/src/Process/InvokedProcess.php +++ b/src/contracts/src/Process/InvokedProcess.php @@ -18,6 +18,8 @@ public function command(): string; /** * Send a signal to the process. + * + * @return $this */ public function signal(int $signal): static; diff --git a/src/contracts/src/Process/ProcessResult.php b/src/contracts/src/Process/ProcessResult.php index 70abb750c9..7763e0021b 100644 --- a/src/contracts/src/Process/ProcessResult.php +++ b/src/contracts/src/Process/ProcessResult.php @@ -48,11 +48,15 @@ public function seeInErrorOutput(string $output): bool; /** * Throw an exception if the process failed. + * + * @return $this */ public function throw(?callable $callback = null): static; /** * Throw an exception if the process failed and the given condition is true. + * + * @return $this */ public function throwIf(bool $condition, ?callable $callback = null): static; } diff --git a/src/contracts/src/Queue/Queue.php b/src/contracts/src/Queue/Queue.php index 2e2930ac99..8c602bacff 100644 --- a/src/contracts/src/Queue/Queue.php +++ b/src/contracts/src/Queue/Queue.php @@ -77,6 +77,8 @@ public function getConnectionName(): string; /** * Set the connection name for the queue. + * + * @return $this */ public function setConnectionName(string $name): static; } diff --git a/src/contracts/src/Validation/DataAwareRule.php b/src/contracts/src/Validation/DataAwareRule.php index e8eb4deb47..37ed765bd1 100644 --- a/src/contracts/src/Validation/DataAwareRule.php +++ b/src/contracts/src/Validation/DataAwareRule.php @@ -8,6 +8,8 @@ interface DataAwareRule { /** * Set the data under validation. + * + * @return $this */ public function setData(array $data): static; } diff --git a/src/contracts/src/Validation/Validator.php b/src/contracts/src/Validation/Validator.php index 984fcdc2d2..93b7e5b079 100644 --- a/src/contracts/src/Validation/Validator.php +++ b/src/contracts/src/Validation/Validator.php @@ -38,11 +38,15 @@ public function failed(): array; /** * Add conditions to a given field based on a Closure. + * + * @return $this */ public function sometimes(array|string $attribute, array|object|string $rules, callable $callback): static; /** * Add an after validation callback. + * + * @return $this */ public function after(callable|string $callback): static; @@ -90,7 +94,7 @@ public function retainRules(array $attributes): static; * Get a validated input container for the validated input. * * @param null|array $keys - * @return ($keys is array ? array : ValidatedInput) + * @return ($keys is array ? array : ValidatedInput) * * @throws ValidationException */ diff --git a/src/contracts/src/Validation/ValidatorAwareRule.php b/src/contracts/src/Validation/ValidatorAwareRule.php index 3e86da4bca..88a9e4b776 100644 --- a/src/contracts/src/Validation/ValidatorAwareRule.php +++ b/src/contracts/src/Validation/ValidatorAwareRule.php @@ -10,6 +10,8 @@ interface ValidatorAwareRule { /** * Set the current validator. + * + * @return $this */ public function setValidator(Validator $validator): static; } diff --git a/src/contracts/src/View/Factory.php b/src/contracts/src/View/Factory.php index 0a428cb370..d1404d8159 100644 --- a/src/contracts/src/View/Factory.php +++ b/src/contracts/src/View/Factory.php @@ -46,11 +46,15 @@ public function creator(array|string $views, Closure|string $callback): array; /** * Add a new namespace to the loader. + * + * @return $this */ public function addNamespace(string $namespace, string|array $hints): static; /** * Replace the namespace hints for the given namespace. + * + * @return $this */ public function replaceNamespace(string $namespace, string|array $hints): static; diff --git a/src/contracts/src/View/View.php b/src/contracts/src/View/View.php index f6e53bc967..61e4b31743 100644 --- a/src/contracts/src/View/View.php +++ b/src/contracts/src/View/View.php @@ -15,6 +15,8 @@ public function name(): string; /** * Add a piece of data to the view. + * + * @return $this */ public function with(string|array $key, mixed $value = null): static; diff --git a/src/cookie/composer.json b/src/cookie/composer.json index 83f86ff92b..b6469f2bfe 100644 --- a/src/cookie/composer.json +++ b/src/cookie/composer.json @@ -31,7 +31,7 @@ "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", "hypervel/support": "^0.4", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/cookie/src/CookieJar.php b/src/cookie/src/CookieJar.php index c6ce2b801a..967affd385 100644 --- a/src/cookie/src/CookieJar.php +++ b/src/cookie/src/CookieJar.php @@ -206,6 +206,8 @@ protected function getPathAndDomain(?string $path, ?string $domain, ?bool $secur * Boot-only. The defaults persist on the worker-lifetime singleton; * per-request mutation races across coroutines and affects every * subsequent request. + * + * @return $this */ public function setDefaultPathAndDomain(string $path, ?string $domain, ?bool $secure = false, ?string $sameSite = null): static { @@ -226,6 +228,8 @@ public function getQueuedCookies(): array /** * Flush the cookies which have been queued for the next request. + * + * @return $this */ public function flushQueuedCookies(): static { diff --git a/src/core/src/Bootstrap/TaskCallback.php b/src/core/src/Bootstrap/TaskCallback.php index 245fec6c0f..c48551ade3 100644 --- a/src/core/src/Bootstrap/TaskCallback.php +++ b/src/core/src/Bootstrap/TaskCallback.php @@ -76,7 +76,7 @@ public function onTask(Server $server, mixed ...$arguments): void $this->dispatcher->dispatch(new TaskTerminated($server, $task)); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($workerContext !== null) { diff --git a/src/core/src/Swoole/StripedLock.php b/src/core/src/Swoole/StripedLock.php index 854fd1169d..075b124102 100644 --- a/src/core/src/Swoole/StripedLock.php +++ b/src/core/src/Swoole/StripedLock.php @@ -162,6 +162,7 @@ protected function acquire(Atomic $lock): void } $spins = 0; + // @phpstan-ignore hypervel.rawSleep (spin-lock backoff across workers must really yield, not be faked) usleep(1); } } diff --git a/src/coroutine/src/Coroutine.php b/src/coroutine/src/Coroutine.php index 29de68ffbf..289e788a1a 100644 --- a/src/coroutine/src/Coroutine.php +++ b/src/coroutine/src/Coroutine.php @@ -70,6 +70,7 @@ public static function defer(callable $callable): void */ public static function sleep(float $seconds): void { + // @phpstan-ignore hypervel.rawSleep (the coroutine sleep itself: hooked usleep() yields the coroutine) usleep(intval($seconds * 1000 * 1000)); } @@ -251,6 +252,7 @@ public static function causedByCancellation(Throwable $exception): bool return false; } + // @phpstan-ignore hypervel.cancellation.exactType (the classification primitive walking the chain) if ($exception instanceof CanceledException) { return true; } @@ -296,6 +298,7 @@ public static function reportExceptions(bool $report = true): void */ protected static function printLog(Throwable $throwable): void { + // @phpstan-ignore hypervel.cancellation.exactType (a terminal reporter: a cleanup failure carrying cancellation is still reported) if ($throwable instanceof CanceledException) { throw $throwable; } @@ -318,9 +321,10 @@ protected static function printLog(Throwable $throwable): void try { error_log((string) $throwable); - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (static::causedByCancellation($exception)) { + throw $exception; + } } } } @@ -330,6 +334,7 @@ protected static function printLog(Throwable $throwable): void */ protected static function reportUncaught(Throwable $throwable): void { + // @phpstan-ignore hypervel.cancellation.exactType (a terminal boundary: a cleanup failure carrying cancellation is still reported) if ($throwable instanceof CanceledException) { return; } diff --git a/src/coroutine/src/Parallel.php b/src/coroutine/src/Parallel.php index ed96397241..12407d6deb 100644 --- a/src/coroutine/src/Parallel.php +++ b/src/coroutine/src/Parallel.php @@ -146,7 +146,8 @@ public function wait(bool $throw = true): array } else { $coroutineIds[] = Coroutine::forkOwned($childCallable, $wrapper, is_array($this->copyContext) ? $this->copyContext : []); } - } catch (CanceledException $exception) { + } catch (Throwable $throwable) { + // Once the child starts, its finally block exclusively owns both releases. if (! $started) { if ($slotAcquired) { $this->concurrentChannel?->pop(); @@ -155,19 +156,12 @@ public function wait(bool $throw = true): array $waitGroup->done(); } - throw $exception; - } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $throwables[$key] = $throwable; unset($results[$key]); - - // Once the child starts, its finally block exclusively owns both releases. - if (! $started) { - if ($slotAcquired) { - $this->concurrentChannel?->pop(); - } - - $waitGroup->done(); - } } } @@ -181,9 +175,13 @@ public function wait(bool $throw = true): array throw new CanceledException('Waiting for parallel child coroutines was canceled.'); } } - } catch (CanceledException $exception) { - $this->cancelChildren($children); - throw $exception; + } catch (Throwable $throwable) { + // Context copying can wrap the cancellation after earlier children started. + if (Coroutine::causedByCancellation($throwable)) { + $this->cancelChildren($children); + } + + throw $throwable; } $this->results = $results; diff --git a/src/coroutine/src/WaitConcurrent.php b/src/coroutine/src/WaitConcurrent.php index 1e55b8e151..902177ec29 100644 --- a/src/coroutine/src/WaitConcurrent.php +++ b/src/coroutine/src/WaitConcurrent.php @@ -75,7 +75,7 @@ protected function startAndTrack(callable $callable, array|false $copyContext = $this->wg->done(); } - if ($exception instanceof CanceledException) { + if (Coroutine::causedByCancellation($exception)) { $this->cancel(); } diff --git a/src/coroutine/src/Waiter.php b/src/coroutine/src/Waiter.php index 2966d4f9c7..fcdc297c46 100644 --- a/src/coroutine/src/Waiter.php +++ b/src/coroutine/src/Waiter.php @@ -205,6 +205,7 @@ protected function awaitChildTermination(int $childCoroutineId): ?CanceledExcept $cancellation ??= new CanceledException('Waiting for a child coroutine was canceled.'); } } catch (CanceledException $exception) { + // @phpstan-ignore hypervel.cancellation.precedence (both values are this coroutine's direct cancellation) $cancellation ??= $exception; } } diff --git a/src/data/src/Eloquent/DataCollectionEloquentCast.php b/src/data/src/Eloquent/DataCollectionEloquentCast.php index 2bc3f34ea8..bfa750cb0c 100644 --- a/src/data/src/Eloquent/DataCollectionEloquentCast.php +++ b/src/data/src/Eloquent/DataCollectionEloquentCast.php @@ -132,7 +132,7 @@ public function set(Model $model, string $key, mixed $value, array $attributes): $itemPayload = $item->transform($context); $payload[$itemKey] = $isAbstractClassCast - ? $this->createMorphEnvelope($item, $itemPayload) + ? $this->createMorphEnvelope($item, $itemPayload) // @phpstan-ignore argument.type (@TODO: Remove once PHPStan keeps class-string instanceof narrowing inside foreach.) : $itemPayload; } diff --git a/src/data/src/Support/Creation/NativeScalar.php b/src/data/src/Support/Creation/NativeScalar.php index 73f36b45e6..63abd6f930 100644 --- a/src/data/src/Support/Creation/NativeScalar.php +++ b/src/data/src/Support/Creation/NativeScalar.php @@ -16,7 +16,7 @@ final class NativeScalar */ public static function int(mixed $value): int { - return $value; // @phpstan-ignore return.type + return $value; } /** @@ -24,7 +24,7 @@ public static function int(mixed $value): int */ public static function float(mixed $value): float { - return $value; // @phpstan-ignore return.type + return $value; } /** @@ -32,7 +32,7 @@ public static function float(mixed $value): float */ public static function string(mixed $value): string { - return $value; // @phpstan-ignore return.type + return $value; } /** @@ -40,6 +40,6 @@ public static function string(mixed $value): string */ public static function bool(mixed $value): bool { - return $value; // @phpstan-ignore return.type + return $value; } } diff --git a/src/data/src/Support/Factories/DataTypeFactory.php b/src/data/src/Support/Factories/DataTypeFactory.php index 8e66fd64e9..6634871b23 100644 --- a/src/data/src/Support/Factories/DataTypeFactory.php +++ b/src/data/src/Support/Factories/DataTypeFactory.php @@ -421,7 +421,9 @@ protected function resolveNativeName( return match ($name) { 'self' => $declaringClass->getName(), 'static' => $targetClass->getName(), - 'parent' => $declaringClass->getParentClass()?->getName() ?? $name, + 'parent' => ($parentClass = $declaringClass->getParentClass()) !== false + ? $parentClass->getName() + : $name, default => $name, }; } @@ -440,7 +442,9 @@ protected function resolvePhpDocName( return match ($name) { 'self' => $declaringClass->getName(), 'static', '$this' => $targetClass->getName(), - 'parent' => $declaringClass->getParentClass()?->getName() ?? $name, + 'parent' => ($parentClass = $declaringClass->getParentClass()) !== false + ? $parentClass->getName() + : $name, default => $this->typeNameResolver->resolve($name, $declaringClass), }; } diff --git a/src/data/src/Support/Transformation/DataTransformer.php b/src/data/src/Support/Transformation/DataTransformer.php index 115d32f74a..ea45140e27 100644 --- a/src/data/src/Support/Transformation/DataTransformer.php +++ b/src/data/src/Support/Transformation/DataTransformer.php @@ -404,7 +404,7 @@ public function paginationInformation( ): array { if ($paginator instanceof LengthAwarePaginator) { return [ - 'links' => $paginator->linkCollection()->toArray(), + 'links' => $paginator->linkCollection()->all(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'first_page_url' => $paginator->url(1), diff --git a/src/database/composer.json b/src/database/composer.json index b0f3a2dfbd..c008e91121 100644 --- a/src/database/composer.json +++ b/src/database/composer.json @@ -58,8 +58,7 @@ "symfony/process": "^8.1" }, "require-dev": { - "fakerphp/faker": "^1.24", - "phpstan/phpstan": "2.3.1" + "fakerphp/faker": "^1.24" }, "suggest": { "fakerphp/faker": "Required to use Eloquent model factories (^1.24)." @@ -80,11 +79,6 @@ "providers": [ "Hypervel\\Database\\DatabaseServiceProvider" ] - }, - "phpstan": { - "includes": [ - "extension.neon" - ] } } } diff --git a/src/database/extension.neon b/src/database/extension.neon deleted file mode 100644 index 421ee28360..0000000000 --- a/src/database/extension.neon +++ /dev/null @@ -1,27 +0,0 @@ -parametersSchema: - eloquentBuilderMacros: arrayOf(string(), string()) - -parameters: - # Builder macros added by model traits' global scopes: trait => interface declaring the macros. - eloquentBuilderMacros: - Hypervel\Database\Eloquent\SoftDeletes: Hypervel\Database\PHPStan\SoftDeletingMacros - -services: - - - class: Hypervel\Database\PHPStan\BuilderMacroResolver - arguments: - macros: %eloquentBuilderMacros% - - - class: Hypervel\Database\PHPStan\ModelScopeMethodResolver - - - class: Hypervel\Database\PHPStan\ForwardedBuilderMethodExtension - tags: - - phpstan.broker.methodsClassReflectionExtension - - - class: Hypervel\Database\PHPStan\ForwardedModelMethodExtension - tags: - - phpstan.broker.methodsClassReflectionExtension - - - class: Hypervel\Database\PHPStan\NamedScopeMethodExtension - tags: - - phpstan.broker.methodsClassReflectionExtension diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index 6227ff96a6..29b6d59d47 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -78,7 +78,6 @@ public function chunk(int $count, callable $callback): bool $remaining = max($remaining - $countResults, 0); } - // @phpstan-ignore argument.type (Eloquent hydrates to TModel, not stdClass) if ($callback($results, $page) === false) { return false; } @@ -205,7 +204,6 @@ public function orderedChunkById(int $count, callable $callback, ?string $column // On each chunk result set, we will pass them to the callback and then let the // developer take care of everything within the callback, which allows us to // keep the memory low for spinning through large result sets for working. - // @phpstan-ignore argument.type (Eloquent hydrates to TModel, not stdClass) if ($callback($results, $page) === false) { return false; } @@ -448,7 +446,6 @@ protected function getPagingAttribute(Model $model, string $column): string */ public function first(array|string $columns = ['*']) { - // @phpstan-ignore return.type (Eloquent hydrates to TModel, not stdClass) return $this->limit(1)->get($columns)->first(); } @@ -493,7 +490,6 @@ public function sole(array|string $columns = ['*']) throw new MultipleRecordsFoundException($count); } - // @phpstan-ignore return.type (Eloquent hydrates to TModel, not stdClass) return $result->first(); } diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 6c647f5125..77b0ce9743 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -1047,11 +1047,7 @@ public function disconnect(): void try { $this->resetTransactionState(); } catch (Throwable $throwable) { - if ($exception === null - || (Coroutine::causedByCancellation($throwable) && ! Coroutine::causedByCancellation($exception)) - ) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { @@ -1128,6 +1124,8 @@ public function isReusable(): bool /** * Register a hook to be run just before a database transaction is started. + * + * @return $this */ public function beforeStartingTransaction(Closure $callback): static { @@ -1138,6 +1136,8 @@ public function beforeStartingTransaction(Closure $callback): static /** * Register a hook to be run just before a database query is executed. + * + * @return $this */ public function beforeExecuting(Closure $callback): static { @@ -1404,6 +1404,8 @@ public function recordsHaveBeenModified(bool $value = true): void /** * Set the record modification state. + * + * @return $this */ public function setRecordModificationState(bool $value): static { @@ -1422,6 +1424,8 @@ public function forgetRecordModificationState(): void /** * Indicate that the connection should use the write connection for reads. + * + * @return $this */ public function useWriteConnectionWhenReading(bool $value = true): static { @@ -1492,6 +1496,8 @@ public function executeSessionStatement(string $sql): void /** * Set the reconnect instance on the connection. + * + * @return $this */ public function setReconnector(callable $reconnector): static { @@ -1614,6 +1620,8 @@ public function getQueryGrammar(): QueryGrammar /** * Set the query grammar used by the connection. + * + * @return $this */ public function setQueryGrammar(Query\Grammars\Grammar $grammar): static { @@ -1632,6 +1640,8 @@ public function getSchemaGrammar(): ?Schema\Grammars\Grammar /** * Set the schema grammar used by the connection. + * + * @return $this */ public function setSchemaGrammar(Schema\Grammars\Grammar $grammar): static { @@ -1650,6 +1660,8 @@ public function getPostProcessor(): Processor /** * Set the query post processor used by the connection. + * + * @return $this */ public function setPostProcessor(Processor $processor): static { @@ -1668,6 +1680,8 @@ public function getEventDispatcher(): ?Dispatcher /** * Set the event dispatcher instance on the connection. + * + * @return $this */ public function setEventDispatcher(Dispatcher $events): static { @@ -1748,6 +1762,8 @@ public function hasPhysicalTransaction(): bool /** * Set the transaction manager instance on the connection. + * + * @return $this */ public function setTransactionManager(DatabaseTransactionsManager $manager): static { @@ -1849,6 +1865,8 @@ public function getDatabaseName(): string /** * Set the name of the connected database. + * + * @return $this */ public function setDatabaseName(string $database): static { @@ -1859,6 +1877,8 @@ public function setDatabaseName(string $database): static /** * Set the read / write type of the connection. + * + * @return $this */ public function setReadWriteType(?string $readWriteType): static { @@ -1887,6 +1907,8 @@ public function getTablePrefix(): string /** * Set the table prefix in use by the connection. + * + * @return $this */ public function setTablePrefix(string $prefix): static { diff --git a/src/database/src/ConnectionResolver.php b/src/database/src/ConnectionResolver.php index 4ebaa6a63e..f275209298 100755 --- a/src/database/src/ConnectionResolver.php +++ b/src/database/src/ConnectionResolver.php @@ -381,11 +381,7 @@ protected function terminateConnections(Closure $terminate): void try { $terminate($connection); } catch (Throwable $throwable) { - if ($exception === null - || (Coroutine::causedByCancellation($throwable) && ! Coroutine::causedByCancellation($exception)) - ) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } finally { CoroutineContext::forget($this->getContextKey($name)); CoroutineContext::forget($this->getLeaseContextKey($name)); diff --git a/src/database/src/Connectors/Connector.php b/src/database/src/Connectors/Connector.php index 90bbfea47b..b915206b3f 100755 --- a/src/database/src/Connectors/Connector.php +++ b/src/database/src/Connectors/Connector.php @@ -74,6 +74,7 @@ protected function createPdoConnection(string $dsn, ?string $username, #[Sensiti // the same: https://github.com/swoole/swoole-src/pull/6272#issuecomment-5931718685 $previous = $exception->getPrevious(); + // @phpstan-ignore hypervel.cancellation.exactType (unwraps the driver's direct wrapping of a canceled connect) throw $previous instanceof CanceledException ? $previous : $exception; } } diff --git a/src/database/src/Console/DumpCommand.php b/src/database/src/Console/DumpCommand.php index b0dcb1a5c5..6cff18416f 100644 --- a/src/database/src/Console/DumpCommand.php +++ b/src/database/src/Console/DumpCommand.php @@ -11,6 +11,8 @@ use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Events\MigrationsPruned; use Hypervel\Database\Events\SchemaDumped; +use Hypervel\Database\Migrations\Migrator; +use Hypervel\Database\Schema\SchemaState; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\Config; use Symfony\Component\Console\Attribute\AsCommand; @@ -43,8 +45,11 @@ public function handle(ConnectionResolverInterface $connections, Dispatcher $dis return self::FAILURE; } + // Dump the connection that migrations run on, which `migrate` loads the dump into. /** @var Connection $connection */ - $connection = $connections->connection($database = $this->input->getOption('database')); + $connection = $connections->connection( + Migrator::resolveMigrationConnectionName($this->input->getOption('database')) + ); $this->schemaState($connection)->dump( $connection, @@ -78,7 +83,7 @@ public function handle(ConnectionResolverInterface $connections, Dispatcher $dis /** * Create a schema state instance for the given connection. */ - protected function schemaState(Connection $connection): mixed + protected function schemaState(Connection $connection): SchemaState { $migrationTable = Config::string('database.migrations.table'); diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index ab50005498..d69271f58b 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -827,6 +827,8 @@ public function setReconnector(callable $reconnector): void * * Tests only. Swaps the singleton's application reference; per-request use * races across coroutines and breaks every concurrent database operation. + * + * @return $this */ public function setApplication(Application $app): static { diff --git a/src/database/src/DatabaseTransactionRecord.php b/src/database/src/DatabaseTransactionRecord.php index 962af1256a..1067f73098 100755 --- a/src/database/src/DatabaseTransactionRecord.php +++ b/src/database/src/DatabaseTransactionRecord.php @@ -94,25 +94,16 @@ public function executeCallbacks(): void */ public function executeCallbacksForRollback(): void { - $cancellation = null; $exception = null; foreach ($this->callbacksForRollback as $callback) { try { $callback(); } catch (Throwable $throwable) { - if (Coroutine::causedByCancellation($throwable)) { - $cancellation ??= $throwable; - } else { - $exception ??= $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } } - if ($cancellation !== null) { - throw $cancellation; - } - if ($exception !== null) { throw $exception; } diff --git a/src/database/src/DatabaseTransactionsManager.php b/src/database/src/DatabaseTransactionsManager.php index cc9997ad87..ee0d949e9c 100755 --- a/src/database/src/DatabaseTransactionsManager.php +++ b/src/database/src/DatabaseTransactionsManager.php @@ -261,25 +261,16 @@ protected function executeCommitCallbacks(Collection $transactions): void */ protected function executeRollbackCallbacks(Collection $transactions): void { - $cancellation = null; $exception = null; foreach ($transactions as $transaction) { try { $transaction->executeCallbacksForRollback(); } catch (Throwable $throwable) { - if (Coroutine::causedByCancellation($throwable)) { - $cancellation ??= $throwable; - } else { - $exception ??= $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } } - if ($cancellation !== null) { - throw $cancellation; - } - if ($exception !== null) { throw $exception; } diff --git a/src/database/src/Eloquent/BroadcastableModelEventOccurred.php b/src/database/src/Eloquent/BroadcastableModelEventOccurred.php index 0783bf753a..4436aac698 100644 --- a/src/database/src/Eloquent/BroadcastableModelEventOccurred.php +++ b/src/database/src/Eloquent/BroadcastableModelEventOccurred.php @@ -53,7 +53,7 @@ public function __construct( public function broadcastOn(): array { $channels = empty($this->channels) - ? ($this->model->broadcastOn($this->event) ?: []) // @phpstan-ignore method.notFound + ? ($this->model->broadcastOn($this->event) ?: []) // @phpstan-ignore method.notFound (provided by BroadcastsEvents on the broadcasting model) : $this->channels; return (new Collection($channels)) @@ -85,6 +85,8 @@ public function broadcastWith(): ?array /** * Manually specify the channels the event should broadcast on. + * + * @return $this */ public function onChannels(array $channels): static { diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 6912a02bfb..36fdc62b23 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -198,6 +198,8 @@ public function make(array $attributes = []): Model /** * Register a new global scope. + * + * @return $this */ public function withGlobalScope(int|string $identifier, Closure|Scope $scope): static { @@ -212,6 +214,8 @@ public function withGlobalScope(int|string $identifier, Closure|Scope $scope): s /** * Remove a registered global scope. + * + * @return $this */ public function withoutGlobalScope(Scope|int|string $scope): static { @@ -228,6 +232,8 @@ public function withoutGlobalScope(Scope|int|string $scope): static /** * Remove all or passed registered global scopes. + * + * @return $this */ public function withoutGlobalScopes(?array $scopes = null): static { @@ -244,6 +250,8 @@ public function withoutGlobalScopes(?array $scopes = null): static /** * Remove all global scopes except the given scopes. + * + * @return $this */ public function withoutGlobalScopesExcept(array $scopes = []): static { @@ -341,7 +349,7 @@ public function except(mixed $models): static /** * Add a basic where clause to the query. * - * @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @param array|(Closure(($operator is null ? static : QueryBuilder)): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column * @return $this */ public function where(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static @@ -366,7 +374,7 @@ public function where(array|Closure|self|QueryBuilder|Relation|Expression|string /** * Add a basic where clause to the query, and return the first result. * - * @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @param array|(Closure(($operator is null ? static : QueryBuilder)): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column * @return null|TModel */ public function firstWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model @@ -377,7 +385,11 @@ public function firstWhere(array|Closure|self|QueryBuilder|Relation|Expression|s /** * Add an "or where" clause to the query. * - * @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * A null operator passed as the second of two arguments becomes a comparison with null, which + * makes a callback a subquery, so the callback type cannot follow the operator here. + * + * @param array|(Closure(__benevolent): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @return $this */ public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static { @@ -393,7 +405,8 @@ public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|stri /** * Add a basic "where not" clause to the query. * - * @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @param array|(Closure(($operator is null ? static : QueryBuilder)): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @return $this */ public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -409,7 +422,8 @@ public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|str /** * Add an "or where not" clause to the query. * - * @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @param array|(Closure(($operator is null ? static : QueryBuilder)): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column + * @return $this */ public function orWhereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static { @@ -418,6 +432,8 @@ public function orWhereNot(array|Closure|self|QueryBuilder|Relation|Expression|s /** * Add an "order by" clause for a timestamp to the query. + * + * @return $this */ public function latest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static { @@ -436,6 +452,8 @@ public function latest(Closure|self|QueryBuilder|Relation|Expression|string|null /** * Add an "order by" clause for a timestamp to the query. + * + * @return $this */ public function oldest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static { @@ -767,7 +785,7 @@ public function incrementOrCreate(array $attributes, string $column = 'count', f { return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function ($instance) use ($column, $step, $extra) { if (! $instance->wasRecentlyCreated) { - $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + $instance->increment($column, $step, $extra); } }); } @@ -1012,6 +1030,8 @@ protected function isNestedUnder(string $relation, string $name): bool /** * Register a closure to be invoked after the query is executed. + * + * @return $this */ public function afterQuery(Closure $callback): static { @@ -1120,7 +1140,9 @@ public function modelKeys(): array /** * Paginate the given query. * - * @return LengthAwarePaginator + * @param null|(Closure(int): ?int)|int $perPage + * @param null|(Closure(): ?int)|int $total + * @return LengthAwarePaginator> * * @throws InvalidArgumentException */ @@ -1145,7 +1167,7 @@ public function paginate(Closure|int|null $perPage = null, array|string $columns /** * Paginate the given query into a simple paginator. * - * @return Paginator + * @return Paginator> */ public function simplePaginate(?int $perPage = null, array|string $columns = ['*'], string $pageName = 'page', ?int $page = null): Paginator { @@ -1167,7 +1189,7 @@ public function simplePaginate(?int $perPage = null, array|string $columns = ['* /** * Paginate the given query into a cursor paginator. * - * @return CursorPaginator + * @return CursorPaginator> */ public function cursorPaginate(?int $perPage = null, array|string $columns = ['*'], string $cursorName = 'cursor', Cursor|string|null $cursor = null): CursorPaginator { @@ -1623,6 +1645,8 @@ public function applyScopes(): static * Keep existing and callback-added WHERE constraints logically separate * so conditions in either group cannot change the meaning of the other, * including when later constraints are added. + * + * @return $this */ public function applyScopeCallback(callable $scope): static { @@ -1721,6 +1745,7 @@ protected function createNestedWhere(array $whereSlice, string $boolean = 'and') * * @param array): mixed)|string>|string $relations * @param (Closure(Relation<*,*,*>): mixed)|string|null $callback + * @return $this */ public function with(array|string $relations, Closure|string|null $callback = null): static { @@ -1737,6 +1762,8 @@ public function with(array|string $relations, Closure|string|null $callback = nu /** * Prevent the specified relations from being eager loaded. + * + * @return $this */ public function without(mixed $relations): static { @@ -1751,6 +1778,7 @@ public function without(mixed $relations): static * Set the relationships that should be eager loaded while removing any previously added eager loading specifications. * * @param array): mixed)|string>|string $relations + * @return $this */ public function withOnly(array|string $relations): static { @@ -1911,6 +1939,8 @@ protected function addNestedWiths(string $name, array $results): array * Specify attributes that should be added to any new models created by this builder. * * The given key / value pairs will also be added as where conditions to the query. + * + * @return $this */ public function withAttributes(Expression|array|string $attributes, mixed $value = null, bool $asConditions = true): static { @@ -1931,6 +1961,8 @@ public function withAttributes(Expression|array|string $attributes, mixed $value /** * Apply query-time casts to the model instance. + * + * @return $this */ public function withCasts(array $casts): static { @@ -1974,6 +2006,8 @@ public function getQuery(): QueryBuilder /** * Set the underlying query builder instance. + * + * @return $this */ public function setQuery(QueryBuilder $query): static { @@ -2000,6 +2034,8 @@ public function getEagerLoads(): array /** * Set the relationships being eagerly loaded. + * + * @return $this */ public function setEagerLoads(array $eagerLoad): static { @@ -2010,6 +2046,8 @@ public function setEagerLoads(array $eagerLoad): static /** * Indicate that the given relationships should not be eagerly loaded. + * + * @return $this */ public function withoutEagerLoad(array $relations): static { @@ -2020,6 +2058,8 @@ public function withoutEagerLoad(array $relations): static /** * Flush the relationships being eagerly loaded. + * + * @return $this */ public function withoutEagerLoads(): static { @@ -2310,6 +2350,8 @@ public function cloneWithoutBindings(array $except): static /** * Register a closure to be invoked on a clone. + * + * @return $this */ public function onClone(Closure $callback): static { diff --git a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php index b4a4063da2..b7034cccca 100644 --- a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php @@ -113,7 +113,7 @@ public function serialize(Model $model, string $key, mixed $value, array $attrib { return (new Collection($value->getArrayCopy())) ->map(fn ($enum) => $this->getStorableEnumValue($enum)) - ->toArray(); + ->all(); } /** diff --git a/src/database/src/Eloquent/Casts/AsEnumCollection.php b/src/database/src/Eloquent/Casts/AsEnumCollection.php index e20d5ebc13..f4d3c02345 100644 --- a/src/database/src/Eloquent/Casts/AsEnumCollection.php +++ b/src/database/src/Eloquent/Casts/AsEnumCollection.php @@ -97,7 +97,7 @@ public function serialize(Model $model, string $key, mixed $value, array $attrib { return (new Collection($value)) ->map(fn ($enum) => $this->getStorableEnumValue($enum)) - ->toArray(); + ->all(); } /** diff --git a/src/database/src/Eloquent/Casts/Attribute.php b/src/database/src/Eloquent/Casts/Attribute.php index d5110093db..7a8956b54b 100644 --- a/src/database/src/Eloquent/Casts/Attribute.php +++ b/src/database/src/Eloquent/Casts/Attribute.php @@ -4,19 +4,29 @@ namespace Hypervel\Database\Eloquent\Casts; +/** + * Unlike Laravel, the templates are variant, so an accessor can declare a wider type than its + * getter returns, such as Attribute for a getter returning a non-empty string. + * Variance requires the callbacks to be read-only to analysis; runtime assignment is unchanged. + * + * @template-covariant TGet + * @template-contravariant TSet + */ class Attribute { /** * The attribute accessor. * - * @var null|callable + * @var null|(callable(mixed, array): TGet) + * @readonly */ public mixed $get; /** * The attribute mutator. * - * @var null|callable + * @var null|(callable(TSet, array): mixed) + * @readonly */ public mixed $set; @@ -32,6 +42,11 @@ class Attribute /** * Create a new attribute accessor / mutator. + * + * @template TValue + * + * @param null|(callable(TValue, array): TGet) $get + * @param null|(callable(TSet, array): mixed) $set */ public function __construct(?callable $get = null, ?callable $set = null) { @@ -41,6 +56,14 @@ public function __construct(?callable $get = null, ?callable $set = null) /** * Create a new attribute accessor / mutator. + * + * @template TValue + * @template TMakeGet = never + * @template TMakeSet = never + * + * @param null|(callable(TValue, array): TMakeGet) $get + * @param null|(callable(TMakeSet, array): mixed) $set + * @return static */ public static function make(?callable $get = null, ?callable $set = null): static { @@ -49,6 +72,12 @@ public static function make(?callable $get = null, ?callable $set = null): stati /** * Create a new attribute accessor. + * + * @template TValue + * @template TMakeGet + * + * @param callable(TValue, array): TMakeGet $get + * @return static */ public static function get(callable $get): static { @@ -57,6 +86,11 @@ public static function get(callable $get): static /** * Create a new attribute mutator. + * + * @template TMakeSet + * + * @param callable(TMakeSet, array): mixed $set + * @return static */ public static function set(callable $set): static { diff --git a/src/database/src/Eloquent/Casts/Json.php b/src/database/src/Eloquent/Casts/Json.php index 28f5b4c93e..70de1d0a71 100644 --- a/src/database/src/Eloquent/Casts/Json.php +++ b/src/database/src/Eloquent/Casts/Json.php @@ -44,6 +44,11 @@ public static function decode(mixed $value, ?bool $associative = true): mixed return (static::$decoder)($value, $associative); } + // SQLite returns a JSON number stored in a json or jsonb column as an int or float, which decodes to itself. + if (is_int($value) || is_float($value)) { + return $value; + } + // An empty string is Eloquent's established empty stored representation. return $value === '' ? null diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 545d62ad17..0fb26f237c 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -14,6 +14,7 @@ use Hypervel\Support\Collection as BaseCollection; use LogicException; use Override; +use Stringable; /** * @template TKey of array-key @@ -107,7 +108,11 @@ public function findOrFail(mixed $key): Model|static /** * Load a set of relationships onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param eager-loads|string $relations + * @return $this */ public function load(array|string $relations): static { @@ -127,7 +132,11 @@ public function load(array|string $relations): static /** * Load a set of aggregations over relationship's column onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations + * @return $this * * @throws MissingAttributeException */ @@ -155,7 +164,7 @@ public function loadAggregate(array|string $relations, Expression|string $column $models = $model->newModelQuery() ->whereKey(array_values($modelKeys)) ->select($keyName) - ->withAggregate($relations, $column, $function) // @phpstan-ignore method.notFound (withAggregate is on Eloquent\Builder; PHPStan loses type through chain) + ->withAggregate($relations, $column, $function) ->get(); if ($models->isEmpty()) { @@ -189,7 +198,11 @@ public function loadAggregate(array|string $relations, Expression|string $column /** * Load a set of relationship counts onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations + * @return $this */ public function loadCount(array|string $relations): static { @@ -199,7 +212,11 @@ public function loadCount(array|string $relations): static /** * Load a set of relationship's max column values onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations + * @return $this */ public function loadMax(array|string $relations, Expression|string $column): static { @@ -209,7 +226,11 @@ public function loadMax(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's min column values onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations + * @return $this */ public function loadMin(array|string $relations, Expression|string $column): static { @@ -219,7 +240,11 @@ public function loadMin(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's column summations onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations + * @return $this */ public function loadSum(array|string $relations, Expression|string $column): static { @@ -229,7 +254,11 @@ public function loadSum(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's average column values onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations + * @return $this */ public function loadAvg(array|string $relations, Expression|string $column): static { @@ -239,7 +268,11 @@ public function loadAvg(array|string $relations, Expression|string $column): sta /** * Load a set of related existences onto the collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param relation-aggregates|string $relations + * @return $this */ public function loadExists(array|string $relations): static { @@ -249,7 +282,11 @@ public function loadExists(array|string $relations): static /** * Load a set of relationships onto the collection if they are not already eager loaded. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $relations + * @phpstan-param eager-loads|string $relations + * @return $this */ public function loadMissing(array|string $relations): static { @@ -351,7 +388,11 @@ function (BaseCollection $models) use ($path): void { /** * Load a set of relationships onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-eager-loads $relations + * @return $this */ public function loadMorph(string $relation, array $relations): static { @@ -368,7 +409,11 @@ public function loadMorph(string $relation, array $relations): static /** * Load a set of relationship counts onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-aggregates $relations + * @return $this */ public function loadMorphCount(string $relation, array $relations): static { @@ -462,7 +507,7 @@ public function merge($items): static * @template TMapValue * * @param callable(TModel, TKey): TMapValue $callback - * @return BaseCollection|static + * @return (array{TMapValue} is array{Model} ? static : BaseCollection) */ #[Override] public function map(callable $callback): BaseCollection @@ -482,7 +527,7 @@ public function map(callable $callback): BaseCollection * @template TMapWithKeysValue * * @param callable(TModel, TKey): array $callback - * @return BaseCollection|static + * @return (array{TMapWithKeysValue} is array{Model} ? static : BaseCollection) */ #[Override] public function mapWithKeys(callable $callback): BaseCollection @@ -496,7 +541,10 @@ public function mapWithKeys(callable $callback): BaseCollection /** * Reload a fresh model instance from the database for all the entities. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string $with + * @phpstan-param eager-loads|string $with * * @throws MissingAttributeException */ @@ -532,7 +580,7 @@ public function fresh(array|string $with = []): static ->with(is_string($with) ? func_get_args() : $with) ->whereIn($model->getKeyName(), array_values($modelKeys)) ->get() - ->getDictionary(); // @phpstan-ignore method.notFound (getDictionary is on Eloquent\Collection; PHPStan loses type through chain) + ->getDictionary(); $freshItems = []; @@ -656,6 +704,7 @@ public function except($keys): static * Make the given, typically visible, attributes hidden across the entire collection. * * @param array|string $attributes + * @return $this */ public function makeHidden(array|string $attributes): static { @@ -667,6 +716,7 @@ public function makeHidden(array|string $attributes): static * Merge the given, typically visible, attributes hidden across the entire collection. * * @param array|string $attributes + * @return $this */ public function mergeHidden(array|string $attributes): static { @@ -678,6 +728,7 @@ public function mergeHidden(array|string $attributes): static * Set the hidden attributes across the entire collection. * * @param array $hidden + * @return $this */ public function setHidden(array $hidden): static { @@ -689,6 +740,7 @@ public function setHidden(array $hidden): static * Make the given, typically hidden, attributes visible across the entire collection. * * @param array|string $attributes + * @return $this */ public function makeVisible(array|string $attributes): static { @@ -700,6 +752,7 @@ public function makeVisible(array|string $attributes): static * Merge the given, typically hidden, attributes visible across the entire collection. * * @param array|string $attributes + * @return $this */ public function mergeVisible(array|string $attributes): static { @@ -711,6 +764,7 @@ public function mergeVisible(array|string $attributes): static * Set the visible attributes across the entire collection. * * @param array $visible + * @return $this */ public function setVisible(array $visible): static { @@ -722,6 +776,7 @@ public function setVisible(array $visible): static * Append an attribute across the entire collection. * * @param array|string $attributes + * @return $this */ public function append(array|string $attributes): static { @@ -733,6 +788,7 @@ public function append(array|string $attributes): static * Sets the appends on every element of the collection, overwriting the existing appends for each. * * @param array $appends + * @return $this */ public function setAppends(array $appends): static { @@ -742,6 +798,8 @@ public function setAppends(array $appends): static /** * Remove appended properties from every element in the collection. + * + * @return $this */ public function withoutAppends(): static { @@ -854,14 +912,18 @@ public function pad(int $size, mixed $value): BaseCollection #[Override] public function partition(mixed $key, mixed $operator = null, mixed $value = null): BaseCollection { - // @phpstan-ignore return.type (parent returns base Collection, we convert to Support Collection) return parent::partition(...func_get_args())->toBase(); } /** * Get the values of a given key. * - * @return BaseCollection + * @template TPluckValue + * @template TPluckKey of array-key|Stringable + * + * @param null|array|(Closure(TModel): TPluckValue)|int|string $value + * @param null|array|(Closure(TModel): TPluckKey)|int|string $key + * @return BaseCollection<($key is Closure ? (TPluckKey is int ? int : string) : array-key), ($value is Closure ? TPluckValue : mixed)> */ #[Override] public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null): BaseCollection @@ -927,6 +989,8 @@ protected function duplicateComparator(bool $strict): callable /** * Enable relationship autoloading for all models in this collection. + * + * @return $this */ public function withRelationshipAutoloading(): static { @@ -1012,7 +1076,6 @@ public function getQueueableRelations(): array return []; } - // @phpstan-ignore method.nonObject (HigherOrderProxy returns Collection, not array) $relations = $this->map->getQueueableRelations()->all(); if (count($relations) === 0 || $relations === [[]]) { diff --git a/src/database/src/Eloquent/Concerns/GuardsAttributes.php b/src/database/src/Eloquent/Concerns/GuardsAttributes.php index 229622b29a..3c7f9fb42e 100644 --- a/src/database/src/Eloquent/Concerns/GuardsAttributes.php +++ b/src/database/src/Eloquent/Concerns/GuardsAttributes.php @@ -155,6 +155,7 @@ public function getFillable(): array * Set the fillable attributes for the model. * * @param array $fillable + * @return $this */ public function fillable(array $fillable): static { @@ -167,6 +168,7 @@ public function fillable(array $fillable): static * Merge new fillable attributes with existing fillable attributes on the model. * * @param array $fillable + * @return $this */ public function mergeFillable(array $fillable): static { @@ -195,6 +197,7 @@ public function getGuarded(): array * Set the guarded attributes for the model. * * @param array $guarded + * @return $this */ public function guard(array $guarded): static { @@ -207,6 +210,7 @@ public function guard(array $guarded): static * Merge new guarded attributes with existing guarded attributes on the model. * * @param array $guarded + * @return $this */ public function mergeGuarded(array $guarded): static { diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index f9cde313ad..62f1c0f834 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -96,7 +96,9 @@ trait HasAttributes /** * The attributes that should be cast. * - * @var array + * A cast may be declared as [$cast, ...$arguments], which the model normalizes to a string when it is constructed. + * + * @var array|string> */ protected array $casts = []; @@ -179,6 +181,8 @@ trait HasAttributes /** * The accessors to append to the model's array form. + * + * @var array */ protected array $appends = []; @@ -759,10 +763,12 @@ protected function mutateAttributeMarkedAttribute(string $key, mixed $value): mi */ protected function mutateAttributeForArray(string $key, mixed $value): mixed { - if ($this->isClassCastable($key)) { + // Unlike Laravel, an enum cast serializes through its get accessor, since instantiating the + // enum as a class cast throws. The accessor lookup also fills the read cache for this + // spelling, as cacheMutatedAttributes() keeps only snake case keys. + if (! $this->isEnumCastable($key) && $this->isClassCastable($key)) { $value = $this->getClassCastableAttributeValue($key, $value); - } elseif (isset(static::$getAttributeMutatorCache[get_class($this)][$key]) - && static::$getAttributeMutatorCache[get_class($this)][$key] === true) { + } elseif ($this->hasAttributeGetMutator($key)) { $value = $this->mutateAttributeMarkedAttribute($key, $value); $value = $value instanceof DateTimeInterface @@ -777,6 +783,8 @@ protected function mutateAttributeForArray(string $key, mixed $value): mixed /** * Merge new casts with existing casts on the model. + * + * @return $this */ public function mergeCasts(array $casts): static { @@ -1219,6 +1227,8 @@ protected function isDateAttribute(string $key): bool /** * Set a given JSON attribute on the model. + * + * @return $this */ public function fillJsonAttribute(string $key, mixed $value): static { @@ -1396,7 +1406,7 @@ protected function asJson(mixed $value, int $flags = 0): string|false /** * Decode the given JSON back into an array or object. */ - public function fromJson(?string $value, bool $asObject = false): mixed + public function fromJson(string|int|float|null $value, bool $asObject = false): mixed { if ($value === null || $value === '') { return null; @@ -1455,7 +1465,6 @@ protected function castAttributeAsHashedString(string $key, #[SensitiveParameter return Hash::make($value); } - /* @phpstan-ignore staticMethod.notFound */ if (! Hash::verifyConfiguration($value)) { throw new RuntimeException("Could not verify the hashed value's configuration."); } @@ -1554,7 +1563,6 @@ protected function asDateTime(mixed $value): CarbonInterface // that is returned back out to the developers after we convert it here. try { $date = Date::createFromFormat($format, $value); - // @phpstan-ignore catch.neverThrown (the Date facade's magic dispatch hides Carbon's @throws from analysis) } catch (InvalidFormatException) { $date = null; } @@ -1621,6 +1629,8 @@ public function getDateFormat(): string /** * Set the date format used by the model. + * + * @return $this */ public function setDateFormat(string $format): static { @@ -1661,7 +1671,7 @@ public function getCasts(): array /** * Get the attributes that should be cast. * - * @return array|string|Stringable> + * @return array|string|Stringable> */ protected function casts(): array { @@ -1746,7 +1756,9 @@ protected function isClassCastable(string $key): bool $castType = $this->parseCasterClass($casts[$key]); - if (in_array($castType, static::$primitiveCastTypes)) { + // Unlike Laravel, a primitive cast in another case, such as BOOLEAN, is primitive here too, as getCastType() + // normalizes it for reads, so writes do not throw. + if (in_array($castType, static::$primitiveCastTypes) || in_array($this->getCastType($key), static::$primitiveCastTypes)) { return $this->castMetadataCache['classCastable'][$key] = false; } @@ -2090,6 +2102,8 @@ public function encodeBinaryAttributeForDatabase(string $key, mixed $value): mix /** * Set the array of model attributes. No checking is done. + * + * @return $this */ public function setRawAttributes(array $attributes, bool $sync = false): static { @@ -2189,6 +2203,8 @@ public function except(mixed $attributes): array /** * Sync the original attributes with the current. + * + * @return $this */ public function syncOriginal(): static { @@ -2199,6 +2215,8 @@ public function syncOriginal(): static /** * Sync a single original attribute with its current value. + * + * @return $this */ public function syncOriginalAttribute(string $attribute): static { @@ -2209,6 +2227,7 @@ public function syncOriginalAttribute(string $attribute): static * Sync multiple original attribute with their current values. * * @param array|string $attributes + * @return $this */ public function syncOriginalAttributes(array|string $attributes): static { @@ -2225,6 +2244,8 @@ public function syncOriginalAttributes(array|string $attributes): static /** * Sync the changed attributes. + * + * @return $this */ public function syncChanges(): static { @@ -2240,6 +2261,7 @@ public function syncChanges(): static * Sync the supplied changed attributes. * * @param array $changes + * @return $this */ private function syncChangesFrom(array $changes): static { @@ -2279,6 +2301,8 @@ public function isClean(array|string|null $attributes = null): bool /** * Discard attribute changes and reset the attributes to their original state. + * + * @return $this */ public function discardChanges(): static { @@ -2488,7 +2512,7 @@ public function originalIsEquivalent(string $key): bool /** * Compare JSON values using the structure exposed by their cast. */ - protected function jsonIsEquivalent(?string $attribute, ?string $original, bool $asObject): bool + protected function jsonIsEquivalent(string|int|float|null $attribute, string|int|float|null $original, bool $asObject): bool { $current = $this->fromJson($attribute, $asObject); @@ -2546,6 +2570,7 @@ protected function transformModelValue(string $key, mixed $value): mixed * Append attributes to query when building a query. * * @param array|string $attributes + * @return $this */ public function append(array|string $attributes): static { @@ -2558,6 +2583,8 @@ public function append(array|string $attributes): static /** * Get the accessors that are being appended to model arrays. + * + * @return array */ public function getAppends(): array { @@ -2566,6 +2593,9 @@ public function getAppends(): array /** * Set the accessors to append to model arrays. + * + * @param array $appends + * @return $this */ public function setAppends(array $appends): static { @@ -2578,6 +2608,7 @@ public function setAppends(array $appends): static * Merge new appended attributes with existing appended attributes on the model. * * @param array $appends + * @return $this */ public function mergeAppends(array $appends): static { @@ -2600,6 +2631,8 @@ public function hasAppended(string $attribute): bool /** * Remove all appended properties from the model. + * + * @return $this */ public function withoutAppends(): static { @@ -2608,6 +2641,8 @@ public function withoutAppends(): static /** * Get the mutated attributes for a given instance. + * + * @return list */ public function getMutatedAttributes(): array { @@ -2665,6 +2700,6 @@ protected static function getAttributeMarkedMutatorMethods(mixed $class): array } return false; - })->map->name->values()->all(); // @phpstan-ignore method.nonObject (HigherOrderProxy: ->map->name returns Collection, not string) + })->map->name->values()->all(); } } diff --git a/src/database/src/Eloquent/Concerns/HasEvents.php b/src/database/src/Eloquent/Concerns/HasEvents.php index 57af583b9c..52f3c2782a 100644 --- a/src/database/src/Eloquent/Concerns/HasEvents.php +++ b/src/database/src/Eloquent/Concerns/HasEvents.php @@ -58,15 +58,13 @@ public static function resolveObserveAttributes(): array { $reflectionClass = new ReflectionClass(static::class); - // @phpstan-ignore function.alreadyNarrowedType (defensive: trait may be used outside Model context) $isEloquentGrandchild = is_subclass_of(static::class, Model::class) && get_parent_class(static::class) !== Model::class; - // @phpstan-ignore return.type (flatten() produces class-strings from getArguments(), PHPStan can't trace) return (new Collection($reflectionClass->getAttributes(ObservedBy::class))) ->map(fn ($attribute) => $attribute->getArguments()) ->flatten() - ->when($isEloquentGrandchild, function (Collection $attributes) { // @phpstan-ignore argument.type (when() callback type inference) + ->when($isEloquentGrandchild, function (Collection $attributes) { // @phpstan-ignore staticMethod.nonObject ($isEloquentGrandchild guarantees parent exists and is Model subclass) return (new Collection(get_parent_class(static::class)::resolveObserveAttributes())) ->merge($attributes); diff --git a/src/database/src/Eloquent/Concerns/HasGlobalScopes.php b/src/database/src/Eloquent/Concerns/HasGlobalScopes.php index 777fa0a843..3d351e6a33 100644 --- a/src/database/src/Eloquent/Concerns/HasGlobalScopes.php +++ b/src/database/src/Eloquent/Concerns/HasGlobalScopes.php @@ -38,14 +38,12 @@ public static function resolveGlobalScopeAttributes(): array $attributes->push(...$trait->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF)); } - // @phpstan-ignore function.alreadyNarrowedType (defensive: trait may be used outside Model context) $isEloquentGrandchild = is_subclass_of(static::class, Model::class) && get_parent_class(static::class) !== Model::class; - // @phpstan-ignore return.type (flatten() produces class-strings from getArguments(), PHPStan can't trace) return $attributes->map(fn ($attribute) => $attribute->getArguments()) ->flatten() - ->when($isEloquentGrandchild, function (Collection $attributes) { // @phpstan-ignore argument.type (when() callback type inference) + ->when($isEloquentGrandchild, function (Collection $attributes) { // @phpstan-ignore staticMethod.nonObject ($isEloquentGrandchild guarantees parent exists and is Model subclass) return (new Collection(get_parent_class(static::class)::resolveGlobalScopeAttributes())) ->merge($attributes); diff --git a/src/database/src/Eloquent/Concerns/HasRelationships.php b/src/database/src/Eloquent/Concerns/HasRelationships.php index cb21554cad..8c1d9fdd21 100644 --- a/src/database/src/Eloquent/Concerns/HasRelationships.php +++ b/src/database/src/Eloquent/Concerns/HasRelationships.php @@ -37,6 +37,8 @@ trait HasRelationships /** * The relationships that should be touched on save. + * + * @var array */ protected array $touches = []; @@ -124,6 +126,8 @@ public function hasRelationAutoloadCallback(): bool /** * Define an automatic relationship autoloader callback for this model and its relations. + * + * @return $this */ public function autoloadRelationsUsing(Closure $callback, mixed $context = null): static { @@ -1081,6 +1085,8 @@ public function unsetRelations(): static /** * Get the relationships that are touched on save. + * + * @return array */ public function getTouchedRelations(): array { @@ -1090,6 +1096,7 @@ public function getTouchedRelations(): array /** * Set the relationships that are touched on save. * + * @param array $touches * @return $this */ public function setTouchedRelations(array $touches): static diff --git a/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php b/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php index 75bb654b89..2a6e93bb1f 100644 --- a/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php +++ b/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php @@ -43,8 +43,10 @@ public function uniqueIds(): array /** * Retrieve the model for a bound value. * - * @param Model|Builder|Relation<*, *, *> $query - * @return Builder|Relation<*, *, *> + * @template TQuery of Builder<*>|Relation<*, *, *> + * + * @param Model|TQuery $query + * @return ($query is Model ? Builder : TQuery) * * @throws ModelNotFoundException */ diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index c58742ab5f..6488be5982 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -26,7 +26,10 @@ use function Hypervel\Support\enum_value; -/** @mixin Builder */ +/** + * Unlike Laravel, this declares no `@mixin Builder`: Builder is its only user, and the bare mixin made + * PHPStan accept on every builder whatever the builder of an unknown model accepts. + */ trait QueriesRelationships { /** @@ -36,6 +39,7 @@ trait QueriesRelationships * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this * * @throws RuntimeException */ @@ -51,7 +55,6 @@ public function has(Relation|string $relation, string $operator = '>=', Expressi } if ($relation instanceof MorphTo) { - // @phpstan-ignore argument.type (callback template types don't narrow through forwarding) return $this->hasMorph($relation, ['*'], $operator, $count, $boolean, $callback); } @@ -89,6 +92,7 @@ public function has(Relation|string $relation, string $operator = '>=', Expressi * Set up recursive calls to has until we finish the nested relation. * * @param (Closure(Builder<*>): mixed)|null $callback + * @return $this */ protected function hasNested(string $relations, string $operator = '>=', ExpressionContract|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static { @@ -111,6 +115,7 @@ protected function hasNested(string $relations, string $operator = '>=', Express * Add a relationship count / exists condition to the query with an "or". * * @param Relation<*, *, *>|string $relation + * @return $this */ public function orHas(Relation|string $relation, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -124,6 +129,7 @@ public function orHas(Relation|string $relation, string $operator = '>=', Expres * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this */ public function doesntHave(Relation|string $relation, string $boolean = 'and', ?Closure $callback = null): static { @@ -134,6 +140,7 @@ public function doesntHave(Relation|string $relation, string $boolean = 'and', ? * Add a relationship count / exists condition to the query with an "or". * * @param Relation<*, *, *>|string $relation + * @return $this */ public function orDoesntHave(Relation|string $relation): static { @@ -147,6 +154,7 @@ public function orDoesntHave(Relation|string $relation): static * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this */ public function whereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -159,6 +167,7 @@ public function whereHas(Relation|string $relation, ?Closure $callback = null, s * Also load the relationship with the same condition. * * @param (Closure(Builder<*>|Relation<*, *, *>): mixed)|null $callback + * @return $this */ public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -179,6 +188,7 @@ public function withWhereHas(string $relation, ?Closure $callback = null, string * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this */ public function orWhereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -192,6 +202,7 @@ public function orWhereHas(Relation|string $relation, ?Closure $callback = null, * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this */ public function whereDoesntHave(Relation|string $relation, ?Closure $callback = null): static { @@ -205,6 +216,7 @@ public function whereDoesntHave(Relation|string $relation, ?Closure $callback = * * @param Relation|string $relation * @param null|(Closure(Builder): mixed) $callback + * @return $this */ public function orWhereDoesntHave(Relation|string $relation, ?Closure $callback = null): static { @@ -219,6 +231,7 @@ public function orWhereDoesntHave(Relation|string $relation, ?Closure $callback * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function hasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', ExpressionContract|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static { @@ -318,6 +331,7 @@ protected function getBelongsToRelation(MorphTo $relation, string $type): Belong * * @param MorphTo<*, *>|string $relation * @param array|string $types + * @return $this */ public function orHasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -332,6 +346,7 @@ public function orHasMorph(MorphTo|string $relation, string|array $types, string * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function doesntHaveMorph(MorphTo|string $relation, string|array $types, string $boolean = 'and', ?Closure $callback = null): static { @@ -343,6 +358,7 @@ public function doesntHaveMorph(MorphTo|string $relation, string|array $types, s * * @param MorphTo<*, *>|string $relation * @param array|string $types + * @return $this */ public function orDoesntHaveMorph(MorphTo|string $relation, string|array $types): static { @@ -357,6 +373,7 @@ public function orDoesntHaveMorph(MorphTo|string $relation, string|array $types) * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function whereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -371,6 +388,7 @@ public function whereHasMorph(MorphTo|string $relation, string|array $types, ?Cl * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function orWhereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { @@ -385,6 +403,7 @@ public function orWhereHasMorph(MorphTo|string $relation, string|array $types, ? * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function whereDoesntHaveMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null): static { @@ -399,6 +418,7 @@ public function whereDoesntHaveMorph(MorphTo|string $relation, string|array $typ * @param MorphTo|string $relation * @param array|string $types * @param null|(Closure(Builder, string): mixed) $callback + * @return $this */ public function orWhereDoesntHaveMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null): static { @@ -412,6 +432,7 @@ public function orWhereDoesntHaveMorph(MorphTo|string $relation, string|array $t * * @param Relation|string $relation * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function whereRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -426,6 +447,8 @@ public function whereRelation(Relation|string $relation, Closure|string|array|Ex /** * Add a basic where clause to a relationship query and eager-load the relationship with the same conditions. + * + * @return $this */ public function withWhereRelation(string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -444,6 +467,7 @@ public function withWhereRelation(string $relation, Closure|string|array|Express * * @param Relation|string $relation * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function orWhereRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -463,6 +487,7 @@ public function orWhereRelation(Relation|string $relation, Closure|string|array| * * @param Relation|string $relation * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function whereDoesntHaveRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -482,6 +507,7 @@ public function whereDoesntHaveRelation(Relation|string $relation, Closure|strin * * @param Relation|string $relation * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -502,6 +528,7 @@ public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|str * @param MorphTo|string $relation * @param array|string $types * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function whereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -518,6 +545,7 @@ public function whereMorphRelation(MorphTo|string $relation, string|array $types * @param MorphTo|string $relation * @param array|string $types * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function orWhereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -534,6 +562,7 @@ public function orWhereMorphRelation(MorphTo|string $relation, string|array $typ * @param MorphTo|string $relation * @param array|string $types * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function whereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -550,6 +579,7 @@ public function whereMorphDoesntHaveRelation(MorphTo|string $relation, string|ar * @param MorphTo|string $relation * @param array|string $types * @param array|(Closure(Builder): mixed)|ExpressionContract|string $column + * @return $this */ public function orWhereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { @@ -563,6 +593,7 @@ public function orWhereMorphDoesntHaveRelation(MorphTo|string $relation, string| * * @param MorphTo<*, *>|string $relation * @param null|iterable|Model|string $model + * @return $this * * @throws ClassMorphViolationException * @throws InvalidArgumentException @@ -580,7 +611,6 @@ public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $b $foreignKey = $relation->getQualifiedForeignKeyName(); if (is_null($model)) { - // @phpstan-ignore return.type (mixin returns $this at runtime) return $this->whereNull($morphType, $boolean); } @@ -613,6 +643,7 @@ public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $b * * @param MorphTo<*, *>|string $relation * @param null|iterable|Model|string $model + * @return $this * * @throws ClassMorphViolationException * @throws InvalidArgumentException @@ -629,7 +660,6 @@ public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string $foreignKey = $relation->getQualifiedForeignKeyName(); if (is_null($model)) { - // @phpstan-ignore return.type (mixin returns $this at runtime) return $this->whereNotNull($morphType, $boolean); } @@ -665,6 +695,7 @@ public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string * * @param MorphTo<*, *>|string $relation * @param null|iterable|Model|string $model + * @return $this */ public function orWhereMorphedTo(MorphTo|string $relation, mixed $model): static { @@ -676,6 +707,7 @@ public function orWhereMorphedTo(MorphTo|string $relation, mixed $model): static * * @param MorphTo<*, *>|string $relation * @param null|iterable|Model|string $model + * @return $this */ public function orWhereNotMorphedTo(MorphTo|string $relation, mixed $model): static { @@ -708,6 +740,7 @@ protected function getMorphTypeForQuery(string $model): string * Add a "belongs to" relationship where clause to the query. * * @param EloquentCollection|Model $related + * @return $this * * @throws InvalidArgumentException * @throws RelationNotFoundException @@ -753,6 +786,7 @@ public function whereBelongsTo(mixed $related, ?string $relationshipName = null, * Add a "BelongsTo" relationship with an "or where" clause to the query. * * @param EloquentCollection|Model $related + * @return $this */ public function orWhereBelongsTo(mixed $related, ?string $relationshipName = null): static { @@ -763,6 +797,7 @@ public function orWhereBelongsTo(mixed $related, ?string $relationshipName = nul * Add a "belongs to many" relationship where clause to the query. * * @param EloquentCollection|Model $related + * @return $this * * @throws InvalidArgumentException * @throws RelationNotFoundException @@ -804,6 +839,7 @@ public function whereAttachedTo(mixed $related, ?string $relationshipName = null * Add a "belongs to many" relationship with an "or where" clause to the query. * * @param EloquentCollection|Model $related + * @return $this * * @throws RuntimeException */ @@ -814,6 +850,8 @@ public function orWhereAttachedTo(mixed $related, ?string $relationshipName = nu /** * Add subselect queries to include an aggregate value for a relationship. + * + * @return $this */ public function withAggregate(mixed $relations, ExpressionContract|string $column, ?string $function = null): static { @@ -900,7 +938,7 @@ public function withAggregate(mixed $relations, ExpressionContract|string $colum $this->selectRaw( sprintf('%s as %s', $this->getQuery()->grammar->compileExistsColumn($query->toSql()), $this->getQuery()->grammar->wrapIdentifier($alias)), $query->getBindings() - )->withCasts([$alias => 'bool']); // @phpstan-ignore method.notFound (selectRaw returns Eloquent\Builder $this, not Query\Builder) + )->withCasts([$alias => 'bool']); } else { $this->selectSub( $function ? $query : $query->limit(1), @@ -930,6 +968,8 @@ protected function getRelationHashedColumn(string $column, Relation $relation): /** * Add subselect queries to count the relations. + * + * @return $this */ public function withCount(mixed $relations): static { @@ -938,6 +978,8 @@ public function withCount(mixed $relations): static /** * Add subselect queries to include the max of the relation's column. + * + * @return $this */ public function withMax(string|array $relation, ExpressionContract|string $column): static { @@ -946,6 +988,8 @@ public function withMax(string|array $relation, ExpressionContract|string $colum /** * Add subselect queries to include the min of the relation's column. + * + * @return $this */ public function withMin(string|array $relation, ExpressionContract|string $column): static { @@ -954,6 +998,8 @@ public function withMin(string|array $relation, ExpressionContract|string $colum /** * Add subselect queries to include the sum of the relation's column. + * + * @return $this */ public function withSum(string|array $relation, ExpressionContract|string $column): static { @@ -962,6 +1008,8 @@ public function withSum(string|array $relation, ExpressionContract|string $colum /** * Add subselect queries to include the average of the relation's column. + * + * @return $this */ public function withAvg(string|array $relation, ExpressionContract|string $column): static { @@ -970,6 +1018,8 @@ public function withAvg(string|array $relation, ExpressionContract|string $colum /** * Add subselect queries to include the existence of related models. + * + * @return $this */ public function withExists(string|array $relation): static { @@ -981,6 +1031,7 @@ public function withExists(string|array $relation): static * * @param Builder<*> $hasQuery * @param Relation<*, *, *> $relation + * @return $this */ protected function addHasWhere(Builder $hasQuery, Relation $relation, string $operator, ExpressionContract|int $count, string $boolean): static { @@ -998,10 +1049,10 @@ protected function addHasWhere(Builder $hasQuery, Relation $relation, string $op * Merge the where constraints from another query to the current query. * * @param Builder<*> $from + * @return $this */ public function mergeConstraintsFrom(Builder $from): static { - // @phpstan-ignore nullCoalesce.offset (defensive fallback) $whereBindings = $from->getQuery()->getRawBindings()['where'] ?? []; $wheres = $from->getQuery()->from !== $this->getQuery()->from @@ -1014,7 +1065,6 @@ public function mergeConstraintsFrom(Builder $from): static // Here we have some other query that we want to merge the where constraints from. We will // copy over any where constraints on the query as well as remove any global scopes the // query might have removed. Then we will return ourselves with the finished merging. - // @phpstan-ignore return.type (mixin method returns $this at runtime) return $this->withoutGlobalScopes( $from->removedScopes() )->mergeWheres( @@ -1039,6 +1089,8 @@ protected function requalifyWhereTables(array $wheres, string $from, string $to) /** * Add a sub-query count clause to this query. + * + * @return $this */ protected function addWhereCountQuery(QueryBuilder $query, string $operator = '>=', ExpressionContract|int $count = 1, string $boolean = 'and'): static { diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php index 3874b019fd..a78a9d7326 100644 --- a/src/database/src/Eloquent/Factories/Factory.php +++ b/src/database/src/Eloquent/Factories/Factory.php @@ -256,13 +256,17 @@ public function createMany(int|iterable|null $records = null): EloquentCollectio $records = array_fill(0, $records, []); } - return new EloquentCollection( - (new Collection($records))->map(function ($record) { - /** @var TModel $model */ - $model = $this->state($record)->create(); + $models = (new Collection($records))->map(function ($record) { + /** @var TModel $model */ + $model = $this->state($record)->create(); - return $model; - }) + return $model; + }); + + // Unlike Laravel, use the model's own collection rather than a plain Eloquent collection, as make() does + // for a count. Like make(), build it without automatic eager loading. + return Model::withoutAutomaticallyEagerLoadingRelationships( + fn () => $this->newModel()->newCollection($models->all()), ); } @@ -383,13 +387,8 @@ public function makeOne(callable|array $attributes = []): Model */ public function make(callable|array $attributes = [], ?Model $parent = null): EloquentCollection|Model { - $autoEagerLoadingEnabled = Model::isAutomaticallyEagerLoadingRelationships(); - - if ($autoEagerLoadingEnabled) { - Model::automaticallyEagerLoadRelationships(false); - } - - try { + // Unlike Laravel, which toggles the worker-wide setting, suppress it only for this coroutine. + return Model::withoutAutomaticallyEagerLoadingRelationships(function () use ($attributes, $parent) { if (! empty($attributes)) { return $this->state($attributes)->make([], $parent); } @@ -411,9 +410,7 @@ public function make(callable|array $attributes = [], ?Model $parent = null): El $this->callAfterMaking($instances); return $instances; - } finally { - Model::automaticallyEagerLoadRelationships($autoEagerLoadingEnabled); - } + }); } /** @@ -432,13 +429,17 @@ public function makeMany(iterable|int|null $records = null): EloquentCollection $records = array_fill(0, $records, []); } - return new EloquentCollection( - (new Collection($records))->map(function ($record) { - /** @var TModel $model */ - $model = $this->state($record)->make(); + $models = (new Collection($records))->map(function ($record) { + /** @var TModel $model */ + $model = $this->state($record)->make(); - return $model; - }) + return $model; + }); + + // Unlike Laravel, use the model's own collection rather than a plain Eloquent collection, as make() does + // for a count. Like make(), build it without automatic eager loading. + return Model::withoutAutomaticallyEagerLoadingRelationships( + fn () => $this->newModel()->newCollection($models->all()), ); } diff --git a/src/database/src/Eloquent/Factories/HasFactory.php b/src/database/src/Eloquent/Factories/HasFactory.php index db57a6709e..5637c6e8a6 100644 --- a/src/database/src/Eloquent/Factories/HasFactory.php +++ b/src/database/src/Eloquent/Factories/HasFactory.php @@ -35,7 +35,7 @@ public static function factory(callable|array|int|null $count = null, callable|a */ protected static function newFactory(): ?Factory { - if (isset(static::$factory)) { // @phpstan-ignore staticProperty.notFound (optional property for legacy factory pattern) + if (isset(static::$factory)) { return static::$factory::new(); } diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index cf543182f6..279dd1b16e 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -82,6 +82,11 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt */ protected const string IGNORE_ON_TOUCH_CONTEXT_KEY = '__database.model.ignore_on_touch'; + /** + * Context key for suppressing automatic eager loading of relationships. + */ + protected const string AUTOMATIC_EAGER_LOADING_SUPPRESSED_CONTEXT_KEY = '__database.model.automatic_eager_loading_suppressed'; + /** * Context key for storing whether broadcasting is enabled. */ @@ -707,6 +712,29 @@ public static function automaticallyEagerLoadRelationships(bool $value = true): static::$modelsShouldAutomaticallyEagerLoadRelationships = $value; } + /** + * Execute a callback without automatically eager loading relationships for the models it retrieves or makes. + * + * Uses Context, so concurrent coroutines keep the boot setting. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn + */ + public static function withoutAutomaticallyEagerLoadingRelationships(callable $callback): mixed + { + $wasSuppressed = CoroutineContext::get(self::AUTOMATIC_EAGER_LOADING_SUPPRESSED_CONTEXT_KEY, false); + + CoroutineContext::set(self::AUTOMATIC_EAGER_LOADING_SUPPRESSED_CONTEXT_KEY, true); + + try { + return $callback(); + } finally { + CoroutineContext::set(self::AUTOMATIC_EAGER_LOADING_SUPPRESSED_CONTEXT_KEY, $wasSuppressed); + } + } + /** * Register a callback that is responsible for handling lazy loading violations. * @@ -814,6 +842,7 @@ public static function isBroadcasting(): bool * Fill the model with an array of attributes. * * @param array $attributes + * @return $this * * @throws MassAssignmentException */ @@ -868,6 +897,7 @@ public function fill(array $attributes): static * Fill the model with an array of attributes. Force mass assignment. * * @param array $attributes + * @return $this */ public function forceFill(array $attributes): static { @@ -997,6 +1027,7 @@ public static function with(array|string $relations): Builder * Eager load relations on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function load(array|string $relations): static { @@ -1013,6 +1044,7 @@ public function load(array|string $relations): static * Eager load relationships on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorph(string $relation, array $relations): static { @@ -1031,6 +1063,7 @@ public function loadMorph(string $relation, array $relations): static * Eager load relations on the model if they are not already eager loaded. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadMissing(array|string $relations): static { @@ -1045,6 +1078,7 @@ public function loadMissing(array|string $relations): static * Eager load relation's column aggregations on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadAggregate(array|string $relations, Expression|string $column, ?string $function = null): static { @@ -1057,6 +1091,7 @@ public function loadAggregate(array|string $relations, Expression|string $column * Eager load relation counts on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadCount(array|string $relations): static { @@ -1069,6 +1104,7 @@ public function loadCount(array|string $relations): static * Eager load relation max column values on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadMax(array|string $relations, Expression|string $column): static { @@ -1079,6 +1115,7 @@ public function loadMax(array|string $relations, Expression|string $column): sta * Eager load relation min column values on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadMin(array|string $relations, Expression|string $column): static { @@ -1089,6 +1126,7 @@ public function loadMin(array|string $relations, Expression|string $column): sta * Eager load relation's column summations on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadSum(array|string $relations, Expression|string $column): static { @@ -1099,6 +1137,7 @@ public function loadSum(array|string $relations, Expression|string $column): sta * Eager load relation average column values on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadAvg(array|string $relations, Expression|string $column): static { @@ -1109,6 +1148,7 @@ public function loadAvg(array|string $relations, Expression|string $column): sta * Eager load related model existence values on the model. * * @param array): mixed)|string>|string $relations + * @return $this */ public function loadExists(array|string $relations): static { @@ -1119,6 +1159,7 @@ public function loadExists(array|string $relations): static * Eager load relationship column aggregation on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphAggregate(string $relation, array $relations, Expression|string $column, ?string $function = null): static { @@ -1137,6 +1178,7 @@ public function loadMorphAggregate(string $relation, array $relations, Expressio * Eager load relationship counts on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphCount(string $relation, array $relations): static { @@ -1147,6 +1189,7 @@ public function loadMorphCount(string $relation, array $relations): static * Eager load relationship max column values on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphMax(string $relation, array $relations, Expression|string $column): static { @@ -1157,6 +1200,7 @@ public function loadMorphMax(string $relation, array $relations, Expression|stri * Eager load relationship min column values on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphMin(string $relation, array $relations, Expression|string $column): static { @@ -1167,6 +1211,7 @@ public function loadMorphMin(string $relation, array $relations, Expression|stri * Eager load relationship column summations on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphSum(string $relation, array $relations, Expression|string $column): static { @@ -1177,6 +1222,7 @@ public function loadMorphSum(string $relation, array $relations, Expression|stri * Eager load relationship average column values on the polymorphic relation of a model. * * @param array): mixed)|string>|string> $relations + * @return $this */ public function loadMorphAvg(string $relation, array $relations, Expression|string $column): static { @@ -1258,7 +1304,7 @@ protected function incrementOrDecrement(string $column, mixed $amount, array $ex * Update the model in the database. * * @param array $attributes - * @param array $options + * @param array{touch?: null|bool, ...} $options */ public function update(array $attributes = [], array $options = []): bool { @@ -1273,7 +1319,7 @@ public function update(array $attributes = [], array $options = []): bool * Update the model in the database within a transaction. * * @param array $attributes - * @param array $options + * @param array{touch?: null|bool, ...} $options * * @throws Throwable */ @@ -1290,7 +1336,7 @@ public function updateOrFail(array $attributes = [], array $options = []): bool * Update the model in the database without raising any events. * * @param array $attributes - * @param array $options + * @param array{touch?: null|bool, ...} $options */ public function updateQuietly(array $attributes = [], array $options = []): bool { @@ -1306,8 +1352,9 @@ public function updateQuietly(array $attributes = [], array $options = []): bool * * @param array $extra */ - protected function incrementQuietly(string $column, float|int $amount = 1, array $extra = []): int|false + protected function incrementQuietly(string $column, float|int $amount = 1, array $extra = []): int { + // fireModelEvent() returns true while events are disabled, so the update never halts with false. return static::withoutEvents( fn () => $this->incrementOrDecrement($column, $amount, $extra, 'increment') ); @@ -1318,8 +1365,9 @@ protected function incrementQuietly(string $column, float|int $amount = 1, array * * @param array $extra */ - protected function decrementQuietly(string $column, float|int $amount = 1, array $extra = []): int|false + protected function decrementQuietly(string $column, float|int $amount = 1, array $extra = []): int { + // fireModelEvent() returns true while events are disabled, so the update never halts with false. return static::withoutEvents( fn () => $this->incrementOrDecrement($column, $amount, $extra, 'decrement') ); @@ -1353,8 +1401,9 @@ protected function decrementEach(array $columns, array $extra = []): int|false * @param array $columns * @param array $extra */ - protected function incrementEachQuietly(array $columns, array $extra = []): int|false + protected function incrementEachQuietly(array $columns, array $extra = []): int { + // fireModelEvent() returns true while events are disabled, so the update never halts with false. return static::withoutEvents( fn () => $this->incrementOrDecrementEach($columns, $extra, 'incrementEach') ); @@ -1366,8 +1415,9 @@ protected function incrementEachQuietly(array $columns, array $extra = []): int| * @param array $columns * @param array $extra */ - protected function decrementEachQuietly(array $columns, array $extra = []): int|false + protected function decrementEachQuietly(array $columns, array $extra = []): int { + // fireModelEvent() returns true while events are disabled, so the update never halts with false. return static::withoutEvents( fn () => $this->incrementOrDecrementEach($columns, $extra, 'decrementEach') ); @@ -1513,7 +1563,7 @@ public function pushQuietly(): bool /** * Save the model to the database without raising any events. * - * @param array $options + * @param array{touch?: null|bool, ...} $options */ public function saveQuietly(array $options = []): bool { @@ -1523,7 +1573,7 @@ public function saveQuietly(array $options = []): bool /** * Save the model to the database. * - * @param array $options + * @param array{touch?: null|bool, ...} $options */ public function save(array $options = []): bool { @@ -1570,7 +1620,7 @@ public function save(array $options = []): bool /** * Save the model to the database, ignoring specific unique constraint conflicts. * - * @param array $options + * @param array{touch?: null|bool, ...} $options * * @throws LogicException */ @@ -1604,7 +1654,7 @@ public function saveOrIgnore(array $options = [], array|string|null $uniqueBy = /** * Save the model to the database within a transaction. * - * @param array $options + * @param array{touch?: null|bool, ...} $options * * @throws Throwable */ @@ -1616,7 +1666,7 @@ public function saveOrFail(array $options = []): bool /** * Perform any actions that are necessary after the model is saved. * - * @param array $options + * @param array{touch?: null|bool, ...} $options */ protected function finishSave(array $options): void { @@ -1937,7 +1987,7 @@ public static function destroy(Collection|BaseCollection|array|int|string $ids): $count = 0; - foreach ($instance->whereIn($key, $ids)->get() as $model) { // @phpstan-ignore method.notFound (Eloquent __call forwarding) + foreach ($instance->whereIn($key, $ids)->get() as $model) { if ($model->delete()) { ++$count; } @@ -2253,6 +2303,8 @@ protected static function isLegacyScopeMethod(string $scope): bool /** * Convert the model instance to an array. + * + * @return array */ public function toArray(): array { @@ -2491,6 +2543,8 @@ public function getConnectionName(): ?string /** * Set the connection associated with the model. + * + * @return $this */ public function setConnection(UnitEnum|string|null $name): static { @@ -2655,6 +2709,8 @@ public function getTable(): string /** * Set the table associated with the model. + * + * @return $this */ public function setTable(string $table): static { @@ -2673,6 +2729,8 @@ public function getKeyName(): ?string /** * Set the primary key for the model. + * + * @return $this */ public function setKeyName(?string $key): static { @@ -2701,6 +2759,8 @@ public function getKeyType(): string /** * Set the data type for the primary key. + * + * @return $this */ public function setKeyType(string $type): static { @@ -2722,6 +2782,8 @@ public function getIncrementing(): bool /** * Set whether IDs are incrementing. + * + * @return $this */ public function setIncrementing(bool $value): static { @@ -2869,8 +2931,10 @@ protected function childRouteBindingRelationshipName(string $childType): string /** * Retrieve the model for a bound value. * - * @param self|Builder|Relation<*, *, *> $query - * @return Builder|Relation<*, *, *> + * @template TQuery of Builder<*>|Relation<*, *, *> + * + * @param self|TQuery $query + * @return ($query is self ? Builder : TQuery) * * @throws ModelNotFoundException */ @@ -2936,6 +3000,8 @@ public function getPerPage(): int /** * Set the number of models to return per page. + * + * @return $this */ public function setPerPage(int $perPage): static { @@ -2981,7 +3047,8 @@ public static function preventsLazyLoading(): bool */ public static function isAutomaticallyEagerLoadingRelationships(): bool { - return static::$modelsShouldAutomaticallyEagerLoadRelationships; + return static::$modelsShouldAutomaticallyEagerLoadRelationships + && ! CoroutineContext::get(self::AUTOMATIC_EAGER_LOADING_SUPPRESSED_CONTEXT_KEY, false); } /** @@ -3227,6 +3294,8 @@ public function __toString(): string /** * Indicate that the object's string representation should be escaped when __toString is invoked. + * + * @return $this */ public function escapeWhenCastingToString(bool $escape = true): static { @@ -3252,15 +3321,14 @@ public function __sleep(): array $this->relationAutoloadCallback = null; $this->relationAutoloadContext = null; - $keys = get_object_vars($this); - - foreach ((new ReflectionClass($this))->getProperties() as $property) { - if ($property->hasHooks()) { - unset($keys[$property->getName()]); - } - } - - return array_keys($keys); + // Unlike Laravel, this names every stored property, including a subclass's private ones under + // PHP's own keys, without running property hooks. Virtual properties store nothing, so the + // array cast leaves them out. Protected names stay plain, as Laravel returns them, so + // overrides that filter parent::__sleep() by name keep working. + return array_map( + static fn (string $key): string => str_starts_with($key, "\0*\0") ? substr($key, 3) : $key, + array_keys((array) $this), + ); } /** diff --git a/src/database/src/Eloquent/ModelNotFoundException.php b/src/database/src/Eloquent/ModelNotFoundException.php index 0757763061..8e9fe4ec9c 100755 --- a/src/database/src/Eloquent/ModelNotFoundException.php +++ b/src/database/src/Eloquent/ModelNotFoundException.php @@ -34,6 +34,7 @@ class ModelNotFoundException extends RecordsNotFoundException * * @param class-string $model * @param null|array|int|string|UnitEnum $ids + * @return $this */ public function setModel(string $model, array|int|string|UnitEnum|null $ids = []): static { diff --git a/src/database/src/Eloquent/Relations/BelongsTo.php b/src/database/src/Eloquent/Relations/BelongsTo.php index e5c37b47c3..63fd619f9c 100644 --- a/src/database/src/Eloquent/Relations/BelongsTo.php +++ b/src/database/src/Eloquent/Relations/BelongsTo.php @@ -223,7 +223,6 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); } - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $this->getQualifiedForeignKeyName(), '=', @@ -246,7 +245,6 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $query->getModel()->setTable($hash); - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->whereColumn( $hash . '.' . $this->ownerKey, '=', diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 92f4268f75..09e850c89f 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -782,7 +782,7 @@ public function incrementOrCreate(array $attributes, string $column = 'count', f { return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void { if (! $instance->wasRecentlyCreated) { - $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + $instance->increment($column, $step, $extra); } }); } @@ -1026,7 +1026,7 @@ public function get(array $columns = ['*']): BaseCollection $models = $builder->addSelect( $this->shouldSelect($columns) - )->getModels(); // @phpstan-ignore method.notFound (addSelect returns Eloquent\Builder, not Query\Builder) + )->getModels(); $this->hydratePivotRelation($models); @@ -1074,7 +1074,8 @@ protected function aliasedPivotColumns(): array /** * Get a paginator for the "select" statement. * - * @return LengthAwarePaginator + * @param null|(Closure(): ?int)|int $total + * @return LengthAwarePaginator> */ public function paginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null, Closure|int|null $total = null): mixed { @@ -1128,6 +1129,8 @@ public function cursorPaginate( /** * Chunk the results of the query. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunk(int $count, callable $callback): bool { @@ -1140,6 +1143,8 @@ public function chunk(int $count, callable $callback): bool /** * Chunk the results of a query by comparing numeric IDs. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool { @@ -1148,6 +1153,8 @@ public function chunkById(int $count, callable $callback, ?string $column = null /** * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool { @@ -1156,6 +1163,8 @@ public function chunkByIdDesc(int $count, callable $callback, ?string $column = /** * Execute a callback over each item while chunking by ID. + * + * @param callable(object{pivot: TPivotModel}&TRelatedModel, int): mixed $callback */ public function eachById(callable $callback, int $count = 1000, ?string $column = null, ?string $alias = null): bool { @@ -1170,6 +1179,8 @@ public function eachById(callable $callback, int $count = 1000, ?string $column /** * Chunk the results of a query by comparing IDs in a given order. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): bool { @@ -1188,6 +1199,8 @@ public function orderedChunkById(int $count, callable $callback, ?string $column /** * Execute a callback over each item while chunking. + * + * @param callable(object{pivot: TPivotModel}&TRelatedModel, int): mixed $callback */ public function each(callable $callback, int $count = 1000): bool { @@ -1296,7 +1309,6 @@ public function cursor(): mixed */ protected function prepareQueryBuilder(): Builder { - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $this->query->addSelect($this->shouldSelect()); } diff --git a/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php index 6a0d5c40b4..4630ed9347 100644 --- a/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php +++ b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php @@ -112,7 +112,6 @@ public function sync(BaseCollection|Model|array|int|string|null $ids, bool $deta // all of the entities that exist in the "current" array but are not in the // array of the new IDs given to the method which will complete the sync. if ($detaching) { - // @phpstan-ignore argument.type ($current is array of IDs from pluck, PHPStan loses type through collection) $detach = array_diff($current, array_keys($records)); if ($detach !== []) { diff --git a/src/database/src/Eloquent/Relations/Concerns/SupportsInverseRelations.php b/src/database/src/Eloquent/Relations/Concerns/SupportsInverseRelations.php index 76b9396388..d5f0517329 100644 --- a/src/database/src/Eloquent/Relations/Concerns/SupportsInverseRelations.php +++ b/src/database/src/Eloquent/Relations/Concerns/SupportsInverseRelations.php @@ -94,7 +94,6 @@ protected function applyInverseRelationToCollection(mixed $models, ?Model $paren $parent ??= $this->getParent(); foreach ($models as $model) { - // @phpstan-ignore instanceof.alwaysTrue (defensive: $models param is mixed at runtime) $model instanceof Model && $this->applyInverseRelationToModel($model, $parent); } diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php index ae5b7d925a..a8c109b0df 100755 --- a/src/database/src/Eloquent/Relations/HasOneOrMany.php +++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php @@ -327,7 +327,7 @@ public function incrementOrCreate(array $attributes, string $column = 'count', f { return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void { if (! $instance->wasRecentlyCreated) { - $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + $instance->increment($column, $step, $extra); } }); } @@ -550,7 +550,6 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $query->getModel()->setTable($hash); - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $this->getQualifiedParentKeyName(), '=', diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 0cb47c967d..dce607e309 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -202,9 +202,9 @@ protected function buildDictionary(EloquentCollection $results): array // models without having to do nested looping which will be quite slow. foreach ($results as $key => $result) { if ($isAssociative) { - $dictionary[$result->hypervel_through_key][$key] = $result; // @phpstan-ignore property.notFound + $dictionary[$result->hypervel_through_key][$key] = $result; // @phpstan-ignore property.notFound (through key alias the relation's query selects) } else { - $dictionary[$result->hypervel_through_key][] = $result; // @phpstan-ignore property.notFound + $dictionary[$result->hypervel_through_key][] = $result; // @phpstan-ignore property.notFound (through key alias the relation's query selects) } } @@ -342,7 +342,7 @@ public function incrementOrCreate(array $attributes, string $column = 'count', f { return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void { if (! $instance->wasRecentlyCreated) { - $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + $instance->increment($column, $step, $extra); } }); } @@ -564,7 +564,8 @@ public function get(array $columns = ['*']): BaseCollection /** * Get a paginator for the "select" statement. * - * @return LengthAwarePaginator + * @param null|(Closure(): ?int)|int $total + * @return LengthAwarePaginator> */ public function paginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null, Closure|int|null $total = null): mixed { @@ -656,6 +657,8 @@ public function allRelatedIds(): BaseCollection /** * Chunk the results of the query. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunk(int $count, callable $callback): bool { @@ -664,6 +667,8 @@ public function chunk(int $count, callable $callback): bool /** * Chunk the results of a query by comparing numeric IDs. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool { @@ -676,6 +681,8 @@ public function chunkById(int $count, callable $callback, ?string $column = null /** * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(EloquentCollection, int): mixed $callback */ public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool { @@ -688,6 +695,8 @@ public function chunkByIdDesc(int $count, callable $callback, ?string $column = /** * Execute a callback over each item while chunking by ID. + * + * @param callable(TRelatedModel, int): mixed $callback */ public function eachById(callable $callback, int $count = 1000, ?string $column = null, ?string $alias = null): bool { @@ -710,6 +719,8 @@ public function cursor(): mixed /** * Execute a callback over each item while chunking. + * + * @param callable(TRelatedModel, int): mixed $callback */ public function each(callable $callback, int $count = 1000): bool { @@ -790,27 +801,33 @@ protected function prepareQueryBuilder(array $columns = ['*']): Builder { $builder = $this->query->applyScopes(); - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $builder->addSelect( $this->shouldSelect($builder->getQuery()->columns ? [] : $columns) ); } + /** + * Add the constraints for an internal relationship existence query. + * + * Unlike Laravel, which inherits Relation's types, the parent query is the declaring model's: Relation's + * parent here is the intermediate model. + * + * @param Builder $query + * @param Builder $parentQuery + * @return Builder + */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder { if ($parentQuery->getQuery()->from === $query->getQuery()->from) { - // @phpstan-ignore argument.type (template types don't narrow through self-relation detection) return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); } if ($parentQuery->getQuery()->from === $this->throughParent->getTable()) { - // @phpstan-ignore argument.type (template types don't narrow through self-relation detection) return $this->getRelationExistenceQueryForThroughSelfRelation($query, $parentQuery, $columns); } $this->performJoin($query); - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $this->getQualifiedLocalKeyName(), '=', @@ -837,7 +854,6 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $query->getModel()->setTable($hash); - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $parentQuery->getQuery()->from . '.' . $this->localKey, '=', @@ -862,7 +878,6 @@ public function getRelationExistenceQueryForThroughSelfRelation(Builder $query, $query->whereNull($hash . '.' . $this->throughParent->getDeletedAtColumn()); } - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $parentQuery->getQuery()->from . '.' . $this->localKey, '=', diff --git a/src/database/src/Eloquent/Relations/Pivot.php b/src/database/src/Eloquent/Relations/Pivot.php index 57b0973773..5da47aabfc 100644 --- a/src/database/src/Eloquent/Relations/Pivot.php +++ b/src/database/src/Eloquent/Relations/Pivot.php @@ -14,6 +14,7 @@ class Pivot extends Model /** * Indicates if the IDs are auto-incrementing. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutIncrementing] would override a subclass's $incrementing = true) public bool $incrementing = false; /** @@ -21,5 +22,6 @@ class Pivot extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; } diff --git a/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php index b48754526d..6d2328b879 100644 --- a/src/database/src/Eloquent/Relations/Relation.php +++ b/src/database/src/Eloquent/Relations/Relation.php @@ -339,7 +339,6 @@ public function rawUpdate(array $attributes = []): int */ public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery): Builder { - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $this->getRelationExistenceQuery( $query, $parentQuery, @@ -358,7 +357,6 @@ public function getRelationExistenceCountQuery(Builder $query, Builder $parentQu */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder { - // @phpstan-ignore return.type (fluent query methods return the Eloquent builder at runtime) return $query->select($columns)->whereColumn( $this->getQualifiedParentKeyName(), '=', diff --git a/src/database/src/Eloquent/SoftDeletingScope.php b/src/database/src/Eloquent/SoftDeletingScope.php index 13da16b9e3..42d2f55dac 100644 --- a/src/database/src/Eloquent/SoftDeletingScope.php +++ b/src/database/src/Eloquent/SoftDeletingScope.php @@ -121,7 +121,6 @@ protected function addWithTrashed(Builder $builder): void return $builder->withoutTrashed(); } - // @phpstan-ignore argument.type (local macros retain their lexical $this at runtime) return $builder->withoutGlobalScope($this); }); } @@ -136,7 +135,6 @@ protected function addWithoutTrashed(Builder $builder): void $builder->macro('withoutTrashed', function (Builder $builder) { $model = $builder->getModel(); - // @phpstan-ignore argument.type (local macros retain their lexical $this at runtime) $builder->withoutGlobalScope($this)->whereNull( $builder->qualifyColumn($model->getQualifiedDeletedAtColumn()) ); @@ -155,7 +153,6 @@ protected function addOnlyTrashed(Builder $builder): void $builder->macro('onlyTrashed', function (Builder $builder) { $model = $builder->getModel(); - // @phpstan-ignore argument.type (local macros retain their lexical $this at runtime) $builder->withoutGlobalScope($this)->whereNotNull( $builder->qualifyColumn($model->getQualifiedDeletedAtColumn()) ); diff --git a/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php b/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php index f60112d170..329ecb3b90 100644 --- a/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php +++ b/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php @@ -5,9 +5,9 @@ namespace Hypervel\Database\Listeners; use Hypervel\Contracts\Container\Container as ContainerContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolver; use Hypervel\Database\Pool\PoolManager; -use Swoole\Coroutine\CanceledException; use Throwable; class DatabaseConnectionLifecycleListener @@ -59,11 +59,7 @@ public function discardProcessConnections(): void try { $this->container->make(PoolManager::class)->purgeAll(); } catch (Throwable $throwable) { - if ($exception === null - || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException) - ) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index 20922b3794..fdda985410 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -910,6 +910,8 @@ public function getFilesystem(): Filesystem /** * Set the output implementation that should be used by the console. + * + * @return $this */ public function setOutput(OutputStyle $output): static { diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php index c99a9e9de5..63f98403c8 100755 --- a/src/database/src/MySqlConnection.php +++ b/src/database/src/MySqlConnection.php @@ -258,6 +258,8 @@ public function getLastInsertId(?string $sequence = null): int|string /** * Set the PDO connection. + * + * @return $this */ public function setPdo(PDO|Closure|null $pdo): static { diff --git a/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php b/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php deleted file mode 100644 index 3effdda4f2..0000000000 --- a/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php +++ /dev/null @@ -1,347 +0,0 @@ - */ - private const array RELATION_NON_DECORATED_METHODS = [ - 'applyscopes', - 'clone', - ]; - - /** @var list */ - private const array RELATION_DOCUMENTED_FLUENT_METHODS = [ - 'wherecan', - 'withcan', - ]; - - /** @var array> */ - private array $passthru = []; - - /** @var array */ - private array $methods = []; - - private readonly OutOfClassScope $scope; - - /** - * Create a forwarded builder method extension. - */ - public function __construct( - private readonly ReflectionProvider $reflectionProvider, - private readonly ModelScopeMethodResolver $scopeMethods, - private readonly BuilderMacroResolver $builderMacros, - ) { - $this->scope = new OutOfClassScope; - } - - /** - * Determine whether the class exposes the forwarded builder method. - */ - public function hasMethod(ClassReflection $classReflection, string $methodName): bool - { - return $this->resolveMethod($classReflection, $methodName) !== null; - } - - /** - * Return the forwarded builder method. - */ - public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection - { - return $this->resolveMethod($classReflection, $methodName) - ?? throw new LogicException(sprintf( - 'Forwarded builder method [%s::%s] was not resolved.', - $classReflection->getName(), - $methodName, - )); - } - - /** - * Resolve and cache a forwarded builder method. - */ - private function resolveMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection - { - $isEloquentHost = $this->isClassOrSubclassOf($classReflection, EloquentBuilder::class); - $isRelationHost = ! $isEloquentHost - && $this->isClassOrSubclassOf($classReflection, Relation::class); - - if (! $isEloquentHost && ! $isRelationHost) { - return null; - } - - // Keep the exact spelling: builder macro names are case-sensitive. - $cacheKey = $classReflection->getCacheKey() . ':' . $methodName; - - if (array_key_exists($cacheKey, $this->methods)) { - $cachedMethod = $this->methods[$cacheKey]; - - return $cachedMethod === false ? null : $cachedMethod; - } - - $method = null; - - if (! $this->hasNativeOrDocumentedMethod($classReflection, $methodName)) { - if ($isEloquentHost) { - $method = $this->resolveEloquentBuilderMethod($classReflection, $methodName); - } else { - $method = $this->resolveRelationMethod($classReflection, $methodName); - } - } - - $this->methods[$cacheKey] = $method ?? false; - - return $method; - } - - /** - * Resolve a query method forwarded by an Eloquent builder. - */ - private function resolveEloquentBuilderMethod( - ClassReflection $classReflection, - string $methodName, - ): ?MethodReflection { - $modelType = $this->templateType($classReflection, EloquentBuilder::class, 'TModel'); - - // Local macros run before named scopes in Builder::__call(). - $macro = $this->builderMacros->resolve($classReflection, $modelType, $methodName); - - if ($macro !== null) { - return $macro; - } - - if ($this->hasNamedScope($modelType, $methodName)) { - return null; - } - - $queryType = $this->queryBuilderType($classReflection, $modelType); - $queryClasses = $queryType->getObjectClassReflections(); - - if (count($queryClasses) !== 1 || ! $queryClasses[0]->hasNativeMethod($methodName)) { - return null; - } - - $method = $queryType->getMethod($methodName, $this->scope); - - if (! $method->isPublic()) { - return null; - } - - if (in_array(strtolower($methodName), $this->passthruMethods($classReflection), strict: true)) { - return $method; - } - - // Eloquent discards ordinary forwarded results, regardless of their declared type. - return new ForwardedFluentMethodReflection($classReflection, $method); - } - - /** - * Resolve a builder method forwarded by a relation. - */ - private function resolveRelationMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection - { - $normalizedMethodName = strtolower($methodName); - $relatedType = $this->templateType($classReflection, Relation::class, 'TRelatedModel'); - $builderType = $this->eloquentBuilderType($relatedType); - $builderClasses = $builderType->getObjectClassReflections(); - - if (count($builderClasses) !== 1) { - return null; - } - - $eloquentBuilder = $builderClasses[0]; - - if ($eloquentBuilder->hasNativeMethod($methodName)) { - $method = $builderType->getMethod($methodName, $this->scope); - - if (! $method->isPublic()) { - return null; - } - - if (in_array($normalizedMethodName, self::RELATION_NON_DECORATED_METHODS, strict: true) - || ! $this->returnsStatic($eloquentBuilder->getNativeMethod($methodName))) { - return $method; - } - - return new ForwardedFluentMethodReflection($classReflection, $method); - } - - $method = $this->resolveEloquentBuilderMethod($eloquentBuilder, $methodName); - - if ($method !== null) { - return $method instanceof ForwardedFluentMethodReflection - ? new ForwardedFluentMethodReflection($classReflection, $method) - : $method; - } - - if ($this->hasNamedScope($relatedType, $methodName) - || ! in_array($normalizedMethodName, self::RELATION_DOCUMENTED_FLUENT_METHODS, strict: true)) { - return null; - } - - $method = $builderType->getMethod($methodName, $this->scope); - - return new ForwardedFluentMethodReflection($classReflection, $method); - } - - /** - * Return the Eloquent builder passthru methods. - * - * @return list - */ - private function passthruMethods(ClassReflection $builderClass): array - { - $className = $builderClass->getName(); - - if (! isset($this->passthru[$className])) { - /** @var list $passthru */ - $passthru = $builderClass->getNativeReflection() - ->getDefaultProperties()['passthru']; - - $this->passthru[$className] = $passthru; - } - - return $this->passthru[$className]; - } - - /** - * Determine whether a class owns a native or documented method. - */ - private function hasNativeOrDocumentedMethod(ClassReflection $classReflection, string $methodName): bool - { - if ($classReflection->hasNativeMethod($methodName)) { - return true; - } - - foreach (array_keys($classReflection->getMethodTags()) as $documentedMethod) { - if (strcasecmp($documentedMethod, $methodName) === 0) { - return true; - } - } - - return false; - } - - /** - * Determine whether the class is or extends the target class. - * - * @param class-string $targetClass - */ - private function isClassOrSubclassOf(ClassReflection $classReflection, string $targetClass): bool - { - return $classReflection->getName() === $targetClass - || $classReflection->isSubclassOfClass($this->reflectionProvider->getClass($targetClass)); - } - - /** - * Return an active template type from a class ancestor. - * - * @param class-string $ancestorClass - */ - private function templateType( - ClassReflection $classReflection, - string $ancestorClass, - string $templateName, - ): Type { - $type = $classReflection - ->getAncestorWithClassName($ancestorClass) - ?->getActiveTemplateTypeMap() - ->getType($templateName); - - return $type ?? throw new LogicException(sprintf( - 'Template type [%s] is not available for [%s].', - $templateName, - $classReflection->getName(), - )); - } - - /** - * Resolve the related model's declared builder, retaining late-static model types. - */ - private function eloquentBuilderType(Type $modelType): Type - { - $modelClasses = $modelType->getObjectClassReflections(); - - if (count($modelClasses) === 1) { - $modelClass = $modelClasses[0]; - $variants = $modelClass->getMethod('query', $this->scope)->getVariants(); - - return ModelScopeTypeResolver::bindToModel($variants[0]->getReturnType(), $modelClass); - } - - return new GenericObjectType(EloquentBuilder::class, [$modelType]); - } - - /** - * Bind forwarded query signatures to model rows without changing raw-query types. - */ - private function queryBuilderType(ClassReflection $builderClass, Type $modelType): Type - { - $variants = $builderClass->getNativeMethod('getQuery')->getVariants(); - $queryType = $variants[0]->getReturnType(); - $queryClasses = $queryType->getObjectClassReflections(); - - if (count($queryClasses) !== 1) { - return $queryType; - } - - $queryClass = $queryClasses[0]; - $templates = $queryClass->getTemplateTypeMap(); - - if ($templates->getType('TKey') === null || $templates->getType('TValue') === null) { - return $queryType; - } - - $types = $queryClass->getActiveTemplateTypeMap()->map( - static fn (string $name, Type $type): Type => match ($name) { - 'TKey' => new IntegerType, - 'TValue' => $modelType, - default => $type, - }, - ); - - return new GenericObjectType($queryClass->getName(), $queryClass->typeMapToList($types)); - } - - /** - * Determine whether a named scope owns dispatch before query forwarding. - */ - private function hasNamedScope(Type $modelType, string $methodName): bool - { - $modelClasses = $modelType->getObjectClassReflections(); - - return count($modelClasses) === 1 - && $this->scopeMethods->resolve($modelClasses[0], $methodName) !== null; - } - - /** - * Determine whether every method variant returns static. - */ - private function returnsStatic(MethodReflection $method): bool - { - foreach ($method->getVariants() as $variant) { - if (! $variant->getReturnType() instanceof StaticType) { - return false; - } - } - - return true; - } -} diff --git a/src/database/src/PHPStan/ForwardedModelMethodExtension.php b/src/database/src/PHPStan/ForwardedModelMethodExtension.php deleted file mode 100644 index cea4eee046..0000000000 --- a/src/database/src/PHPStan/ForwardedModelMethodExtension.php +++ /dev/null @@ -1,151 +0,0 @@ - */ - private array $methods = []; - - private readonly OutOfClassScope $scope; - - /** - * Create a forwarded model method extension. - */ - public function __construct( - private readonly ReflectionProvider $reflectionProvider, - private readonly ModelScopeMethodResolver $scopeMethods, - private readonly BuilderMacroResolver $builderMacros, - ) { - $this->scope = new OutOfClassScope; - } - - /** - * Determine whether the model exposes the forwarded builder method. - */ - public function hasMethod(ClassReflection $classReflection, string $methodName): bool - { - return $this->resolveMethod($classReflection, $methodName) !== null; - } - - /** - * Return the forwarded builder method. - */ - public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection - { - return $this->resolveMethod($classReflection, $methodName) - ?? throw new LogicException(sprintf( - 'Forwarded model method [%s::%s] was not resolved.', - $classReflection->getName(), - $methodName, - )); - } - - /** - * Resolve and cache a builder method forwarded through a model. - */ - private function resolveMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection - { - if (! $this->isModel($classReflection)) { - return null; - } - - // Keep the exact spelling: builder macro names are case-sensitive. - $cacheKey = $classReflection->getCacheKey() . ':' . $methodName; - - if (array_key_exists($cacheKey, $this->methods)) { - $cachedMethod = $this->methods[$cacheKey]; - - return $cachedMethod === false ? null : $cachedMethod; - } - - $method = null; - - if (! $this->hasNativeOrDocumentedMethod($classReflection, $methodName)) { - $builderType = $this->activeBuilderType($classReflection); - $scopeMethod = $this->scopeMethods->resolve($classReflection, $methodName); - - // Native builder methods and local macros run before named scopes. - if (($scopeMethod === null - || $this->hasNativeMethod($builderType, $methodName) - || $this->builderMacros->hasMacro($classReflection, $methodName)) - && $builderType->hasMethod($methodName)->yes()) { - $method = new ForwardedModelMethodReflection( - $classReflection, - $builderType->getMethod($methodName, $this->scope), - ); - } - } - - $this->methods[$cacheKey] = $method ?? false; - - return $method; - } - - /** - * Return the active query builder type for a model. - * - * Preserve the raw static type so PHPStan can rebind it to the called-on model. - */ - private function activeBuilderType(ClassReflection $classReflection): Type - { - $variants = $classReflection->getMethod('query', $this->scope)->getVariants(); - - return $variants[0]->getReturnType(); - } - - /** - * Determine whether an active builder owns a native method. - */ - private function hasNativeMethod(Type $builderType, string $methodName): bool - { - foreach ($builderType->getObjectClassReflections() as $builderClass) { - if ($builderClass->hasNativeMethod($methodName)) { - return true; - } - } - - return false; - } - - /** - * Determine whether a class owns a native or documented method. - */ - private function hasNativeOrDocumentedMethod(ClassReflection $classReflection, string $methodName): bool - { - if ($classReflection->hasNativeMethod($methodName)) { - return true; - } - - foreach (array_keys($classReflection->getMethodTags()) as $documentedMethod) { - if (strcasecmp($documentedMethod, $methodName) === 0) { - return true; - } - } - - return false; - } - - /** - * Determine whether the class is an Eloquent model. - */ - private function isModel(ClassReflection $classReflection): bool - { - return $classReflection->getName() === Model::class - || $classReflection->isSubclassOfClass($this->reflectionProvider->getClass(Model::class)); - } -} diff --git a/src/database/src/PHPStan/ModelScopeMethodResolver.php b/src/database/src/PHPStan/ModelScopeMethodResolver.php deleted file mode 100644 index bf3d8d3cb2..0000000000 --- a/src/database/src/PHPStan/ModelScopeMethodResolver.php +++ /dev/null @@ -1,63 +0,0 @@ -> */ - private array $methods = []; - - /** - * Resolve a model scope by its exposed name. - */ - public function resolve(ClassReflection $modelClass, string $scope): ?MethodReflection - { - return $this->methods($modelClass)[strtolower($scope)] ?? null; - } - - /** - * Return the named scopes exposed by a model. - * - * @return array - */ - private function methods(ClassReflection $modelClass): array - { - $cacheKey = $modelClass->getCacheKey(); - - if (isset($this->methods[$cacheKey])) { - return $this->methods[$cacheKey]; - } - - $legacyScopes = []; - $attributedScopes = []; - - foreach ($modelClass->getNativeReflection()->getMethods() as $method) { - if ($method->isPrivate()) { - continue; - } - - $methodName = $method->getName(); - - if (strncasecmp($methodName, 'scope', 5) === 0 - && strlen($methodName) > 5 - && ! ctype_lower($methodName[5])) { - $legacyScopes[strtolower(substr($methodName, 5))] = $modelClass->getNativeMethod($methodName); - } - - if ($method->getAttributes(Scope::class) !== []) { - $attributedScopes[strtolower($methodName)] = $modelClass->getNativeMethod($methodName); - } - } - - return $this->methods[$cacheKey] = array_replace($legacyScopes, $attributedScopes); - } -} diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index 4d884c4dcb..8c25404035 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -8,6 +8,7 @@ use ErrorException; use Exception; use Generator; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Events\StatementPrepared; use Hypervel\Support\Str; use PDO; @@ -15,7 +16,6 @@ use PDOStatement; use RuntimeException; use stdClass; -use Swoole\Coroutine\CanceledException; use Throwable; use WeakMap; @@ -722,6 +722,8 @@ protected function hasDriverResources(): bool /** * Set the PDO connection. + * + * @return $this */ public function setPdo(PDO|Closure|null $pdo): static { @@ -737,6 +739,8 @@ public function setPdo(PDO|Closure|null $pdo): static /** * Set the PDO connection used for reading. + * + * @return $this */ public function setReadPdo(PDO|Closure|null $pdo): static { @@ -748,6 +752,8 @@ public function setReadPdo(PDO|Closure|null $pdo): static /** * Set the read PDO connection configuration. + * + * @return $this */ public function setReadPdoConfig(array $config): static { @@ -787,11 +793,8 @@ protected function disconnectDriverResources(): void } catch (Throwable $throwable) { $this->markSessionStateUnknown($handle); - if (! $this->causedByLostConnection($throwable) - && ($exception === null - || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) - ) { - $exception = $throwable; + if (! $this->causedByLostConnection($throwable)) { + $exception = Coroutine::preferCancellation($exception, $throwable); } } } @@ -880,7 +883,7 @@ public function ping(): bool return true; } catch (Throwable $exception) { - if ($exception instanceof CanceledException) { + if (Coroutine::causedByCancellation($exception)) { throw $exception; } diff --git a/src/database/src/Pool/DatabasePool.php b/src/database/src/Pool/DatabasePool.php index 3c28e41d98..19a7f99543 100644 --- a/src/database/src/Pool/DatabasePool.php +++ b/src/database/src/Pool/DatabasePool.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\ConnectionPool\UsageTracker; use Hypervel\Contracts\Container\Container; use Hypervel\Coordinator\Timer; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionName; use Hypervel\Database\Connectors\ConnectionFactory; @@ -524,16 +525,18 @@ protected function heartbeatConnection(PooledConnection $connection): void || ($connection->isIdleExpired($now) && $this->getManagedCount() > $this->options->minRetainedConnections); $healthy = ! $expired && $connection->ping($this->options->heartbeatTimeout); - } catch (CanceledException $cancellation) { - try { - $this->discardHeartbeatConnection($connection); - } catch (CanceledException) { - } catch (Throwable $exception) { - $this->report($exception); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + try { + $this->discardHeartbeatConnection($connection); + } catch (CanceledException) { + } catch (Throwable $cleanupException) { + $this->report($cleanupException); + } + + throw $exception; } - throw $cancellation; - } catch (Throwable $exception) { $this->report('Database heartbeat failed: ' . $exception); $healthy = false; } diff --git a/src/database/src/Pool/PoolManager.php b/src/database/src/Pool/PoolManager.php index 1b8ab14181..eb80d00850 100644 --- a/src/database/src/Pool/PoolManager.php +++ b/src/database/src/Pool/PoolManager.php @@ -5,9 +5,9 @@ namespace Hypervel\Database\Pool; use Hypervel\Contracts\Container\Container; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionName; use Hypervel\Database\Connectors\ConnectionFactory; -use Swoole\Coroutine\CanceledException; use Throwable; class PoolManager @@ -58,12 +58,8 @@ public function pool(string $name): DatabasePool } catch (Throwable $failure) { try { $pool->close(); - } catch (CanceledException $cancellation) { - if (! $failure instanceof CanceledException) { - throw $cancellation; - } - } catch (Throwable) { - // Preserve the activation failure over an ordinary cleanup failure. + } catch (Throwable $cleanupFailure) { + $failure = Coroutine::preferCancellation($failure, $cleanupFailure); } throw $failure; @@ -216,25 +212,18 @@ public function purgeAll(): void */ private function closePools(array $pools): void { - $firstException = null; - $firstCancellation = null; + $exception = null; foreach ($pools as $pool) { try { $pool->close(); - } catch (CanceledException $exception) { - $firstCancellation ??= $exception; - } catch (Throwable $exception) { - $firstException ??= $exception; + } catch (Throwable $throwable) { + $exception = Coroutine::preferCancellation($exception, $throwable); } } - if ($firstCancellation !== null) { - throw $firstCancellation; - } - - if ($firstException !== null) { - throw $firstException; + if ($exception !== null) { + throw $exception; } } } diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 44c6f58cd6..af0b5c4650 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -290,6 +290,8 @@ public function __construct( /** * Set the columns to be selected. + * + * @return $this */ public function select(mixed $columns = ['*']): static { @@ -313,6 +315,7 @@ public function select(mixed $columns = ['*']): static * Add a subselect expression to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this * * @throws InvalidArgumentException */ @@ -328,6 +331,8 @@ public function selectSub(Closure|self|EloquentBuilder|Relation|string $query, s /** * Add a select expression to the query. + * + * @return $this */ public function selectExpression(ExpressionContract|string $expression, string $as): static { @@ -338,6 +343,8 @@ public function selectExpression(ExpressionContract|string $expression, string $ /** * Add a new "raw" select expression to the query. + * + * @return $this */ public function selectRaw(string $expression, array $bindings = []): static { @@ -354,6 +361,7 @@ public function selectRaw(string $expression, array $bindings = []): static * Makes "from" fetch from a subquery. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this * * @throws InvalidArgumentException */ @@ -370,6 +378,8 @@ public function fromSub(Closure|self|EloquentBuilder|Relation|string $query, str /** * Add a raw "from" clause to the query. + * + * @return $this */ public function fromRaw(ExpressionContract|string $expression, mixed $bindings = []): static { @@ -454,6 +464,8 @@ protected function prependDatabaseNameIfCrossDatabaseQuery(self $query): self /** * Add a new select column to the query. + * + * @return $this */ public function addSelect(mixed $column): static { @@ -500,6 +512,7 @@ public function getDefaultSelectColumn(): string * Add a vector-similarity selection to the query. * * @param array|Arrayable>|Collection|string $vector + * @return $this * * @throws JsonException */ @@ -508,7 +521,7 @@ public function selectVectorDistance(ExpressionContract|string $column, Collecti $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); } $this->addBinding( @@ -533,6 +546,8 @@ public function selectVectorDistance(ExpressionContract|string $column, Collecti /** * Force the query to only return distinct results. + * + * @return $this */ public function distinct(): static { @@ -551,6 +566,7 @@ public function distinct(): static * Set the table which the query is targeting. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $table + * @return $this */ public function from(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $table, ?string $as = null): static { @@ -581,6 +597,8 @@ public function getFromAlias(): ?string /** * Add an index hint to suggest a query index. + * + * @return $this */ public function useIndex(string $index): static { @@ -591,6 +609,8 @@ public function useIndex(string $index): static /** * Add an index hint to force a query index. + * + * @return $this */ public function forceIndex(string $index): static { @@ -601,6 +621,8 @@ public function forceIndex(string $index): static /** * Add an index hint to ignore a query index. + * + * @return $this */ public function ignoreIndex(string $index): static { @@ -611,6 +633,8 @@ public function ignoreIndex(string $index): static /** * Add a "join" clause to the query. + * + * @return $this */ public function join(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, mixed $second = null, string $type = 'inner', bool $where = false): static { @@ -643,6 +667,8 @@ public function join(ExpressionContract|string $table, Closure|ExpressionContrac /** * Add a "join where" clause to the query. + * + * @return $this */ public function joinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second, string $type = 'inner'): static { @@ -653,6 +679,7 @@ public function joinWhere(ExpressionContract|string $table, Closure|ExpressionCo * Add a "subquery join" clause to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this * * @throws InvalidArgumentException */ @@ -676,6 +703,7 @@ public function joinSub(Closure|self|EloquentBuilder|Relation|string $query, str * Add a "lateral join" clause to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function joinLateral(Closure|self|EloquentBuilder|Relation|string $query, string $as, string $type = 'inner'): static { @@ -694,6 +722,7 @@ public function joinLateral(Closure|self|EloquentBuilder|Relation|string $query, * Add a lateral left join to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function leftJoinLateral(Closure|self|EloquentBuilder|Relation|string $query, string $as): static { @@ -702,6 +731,8 @@ public function leftJoinLateral(Closure|self|EloquentBuilder|Relation|string $qu /** * Add a left join to the query. + * + * @return $this */ public function leftJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -710,6 +741,8 @@ public function leftJoin(ExpressionContract|string $table, Closure|ExpressionCon /** * Add a "join where" clause to the query. + * + * @return $this */ public function leftJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second): static { @@ -720,6 +753,7 @@ public function leftJoinWhere(ExpressionContract|string $table, Closure|Expressi * Add a subquery left join to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function leftJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -728,6 +762,8 @@ public function leftJoinSub(Closure|self|EloquentBuilder|Relation|string $query, /** * Add a right join to the query. + * + * @return $this */ public function rightJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -736,6 +772,8 @@ public function rightJoin(ExpressionContract|string $table, Closure|ExpressionCo /** * Add a "right join where" clause to the query. + * + * @return $this */ public function rightJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second): static { @@ -746,6 +784,7 @@ public function rightJoinWhere(ExpressionContract|string $table, Closure|Express * Add a subquery right join to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function rightJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -754,6 +793,8 @@ public function rightJoinSub(Closure|self|EloquentBuilder|Relation|string $query /** * Add a "cross join" clause to the query. + * + * @return $this */ public function crossJoin(ExpressionContract|string $table, Closure|ExpressionContract|string|null $first = null, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -770,6 +811,7 @@ public function crossJoin(ExpressionContract|string $table, Closure|ExpressionCo * Add a subquery cross join to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function crossJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as): static { @@ -786,6 +828,8 @@ public function crossJoinSub(Closure|self|EloquentBuilder|Relation|string $query /** * Add a straight join to the query. + * + * @return $this */ public function straightJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -794,6 +838,8 @@ public function straightJoin(ExpressionContract|string $table, Closure|Expressio /** * Add a straight join where clause to the query. + * + * @return $this */ public function straightJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second): static { @@ -804,6 +850,7 @@ public function straightJoinWhere(ExpressionContract|string $table, Closure|Expr * Add a subquery straight join to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query + * @return $this */ public function straightJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -828,6 +875,8 @@ protected function newJoinLateralClause(self $parentQuery, string $type, Express /** * Merge an array of "where" clauses and bindings. + * + * @return $this */ public function mergeWheres(array $wheres, array $bindings): static { @@ -842,6 +891,8 @@ public function mergeWheres(array $wheres, array $bindings): static /** * Add a basic "where" clause to the query. + * + * @return $this */ public function where(Closure|self|EloquentBuilder|Relation|ExpressionContract|array|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -949,6 +1000,8 @@ public function where(Closure|self|EloquentBuilder|Relation|ExpressionContract|a /** * Add an array of "where" clauses to the query. + * + * @return $this */ protected function addArrayOfWheres(array $column, string $boolean, string $method = 'where'): static { @@ -1011,6 +1064,8 @@ protected function isBitwiseOperator(string $operator): bool /** * Add an "or where" clause to the query. + * + * @return $this */ public function orWhere(Closure|self|EloquentBuilder|Relation|ExpressionContract|array|string $column, mixed $operator = null, mixed $value = null): static { @@ -1025,6 +1080,8 @@ public function orWhere(Closure|self|EloquentBuilder|Relation|ExpressionContract /** * Add a basic "where not" clause to the query. + * + * @return $this */ public function whereNot(Closure|self|EloquentBuilder|Relation|ExpressionContract|array|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -1039,6 +1096,8 @@ public function whereNot(Closure|self|EloquentBuilder|Relation|ExpressionContrac /** * Add an "or where not" clause to the query. + * + * @return $this */ public function orWhereNot(Closure|self|EloquentBuilder|Relation|ExpressionContract|array|string $column, mixed $operator = null, mixed $value = null): static { @@ -1047,6 +1106,8 @@ public function orWhereNot(Closure|self|EloquentBuilder|Relation|ExpressionContr /** * Add a "where" clause comparing two columns to the query. + * + * @return $this */ public function whereColumn(ExpressionContract|string|array $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null, string $boolean = 'and'): static { @@ -1078,6 +1139,8 @@ public function whereColumn(ExpressionContract|string|array $first, ExpressionCo /** * Add an "or where" clause comparing two columns to the query. + * + * @return $this */ public function orWhereColumn(ExpressionContract|string|array $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { @@ -1089,6 +1152,7 @@ public function orWhereColumn(ExpressionContract|string|array $first, Expression * * @param array|Arrayable>|Collection|string $vector * @param float $minSimilarity A value between 0.0 and 1.0, where 1.0 is identical. + * @return $this * * @throws JsonException */ @@ -1097,7 +1161,7 @@ public function whereVectorSimilarTo(ExpressionContract|string $column, Collecti $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); } $this->whereVectorDistanceLessThan($column, $vector, 1 - $minSimilarity); @@ -1113,6 +1177,7 @@ public function whereVectorSimilarTo(ExpressionContract|string $column, Collecti * Add a vector distance "where" clause to the query. * * @param array|Arrayable>|Collection|string $vector + * @return $this * * @throws JsonException */ @@ -1121,7 +1186,7 @@ public function whereVectorDistanceLessThan(ExpressionContract|string $column, C $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); } return $this->whereRaw( @@ -1143,6 +1208,7 @@ public function whereVectorDistanceLessThan(ExpressionContract|string $column, C * Add a vector distance "or where" clause to the query. * * @param array|Arrayable>|Collection|string $vector + * @return $this * * @throws JsonException */ @@ -1153,6 +1219,8 @@ public function orWhereVectorDistanceLessThan(ExpressionContract|string $column, /** * Add a raw "where" clause to the query. + * + * @return $this */ public function whereRaw(ExpressionContract|string $sql, mixed $bindings = [], string $boolean = 'and'): static { @@ -1165,6 +1233,8 @@ public function whereRaw(ExpressionContract|string $sql, mixed $bindings = [], s /** * Add a raw "or where" clause to the query. + * + * @return $this */ public function orWhereRaw(string $sql, mixed $bindings = []): static { @@ -1173,6 +1243,8 @@ public function orWhereRaw(string $sql, mixed $bindings = []): static /** * Add a "where binary" clause to the query. + * + * @return $this */ public function whereBinary(ExpressionContract|string $column, string $value, string $boolean = 'and', bool $not = false): static { @@ -1187,6 +1259,8 @@ public function whereBinary(ExpressionContract|string $column, string $value, st /** * Add an "or where binary" clause to the query. + * + * @return $this */ public function orWhereBinary(ExpressionContract|string $column, string $value): static { @@ -1195,6 +1269,8 @@ public function orWhereBinary(ExpressionContract|string $column, string $value): /** * Add a "where not binary" clause to the query. + * + * @return $this */ public function whereNotBinary(ExpressionContract|string $column, string $value, string $boolean = 'and'): static { @@ -1203,6 +1279,8 @@ public function whereNotBinary(ExpressionContract|string $column, string $value, /** * Add an "or where not binary" clause to the query. + * + * @return $this */ public function orWhereNotBinary(ExpressionContract|string $column, string $value): static { @@ -1211,6 +1289,8 @@ public function orWhereNotBinary(ExpressionContract|string $column, string $valu /** * Add a "where like" clause to the query. + * + * @return $this */ public function whereLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false, string $boolean = 'and', bool $not = false): static { @@ -1229,6 +1309,8 @@ public function whereLike(ExpressionContract|string $column, string $value, bool /** * Add an "or where like" clause to the query. + * + * @return $this */ public function orWhereLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false): static { @@ -1237,6 +1319,8 @@ public function orWhereLike(ExpressionContract|string $column, string $value, bo /** * Add a "where not like" clause to the query. + * + * @return $this */ public function whereNotLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false, string $boolean = 'and'): static { @@ -1245,6 +1329,8 @@ public function whereNotLike(ExpressionContract|string $column, string $value, b /** * Add an "or where not like" clause to the query. + * + * @return $this */ public function orWhereNotLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false): static { @@ -1253,6 +1339,8 @@ public function orWhereNotLike(ExpressionContract|string $column, string $value, /** * Add a null-safe equality clause to the query. + * + * @return $this */ public function whereNullSafeEquals(ExpressionContract|string $column, mixed $value, string $boolean = 'and'): static { @@ -1273,6 +1361,8 @@ public function whereNullSafeEquals(ExpressionContract|string $column, mixed $va /** * Add an "or" null-safe equality clause to the query. + * + * @return $this */ public function orWhereNullSafeEquals(ExpressionContract|string $column, mixed $value): static { @@ -1282,6 +1372,8 @@ public function orWhereNullSafeEquals(ExpressionContract|string $column, mixed $ /** * Add a "where in" clause to the query. * + * @return $this + * * @throws InvalidArgumentException */ public function whereIn(ExpressionContract|string $column, mixed $values, string $boolean = 'and', bool $not = false): static @@ -1322,6 +1414,8 @@ public function whereIn(ExpressionContract|string $column, mixed $values, string /** * Add an "or where in" clause to the query. + * + * @return $this */ public function orWhereIn(ExpressionContract|string $column, mixed $values): static { @@ -1330,6 +1424,8 @@ public function orWhereIn(ExpressionContract|string $column, mixed $values): sta /** * Add a "where not in" clause to the query. + * + * @return $this */ public function whereNotIn(ExpressionContract|string $column, mixed $values, string $boolean = 'and'): static { @@ -1338,6 +1434,8 @@ public function whereNotIn(ExpressionContract|string $column, mixed $values, str /** * Add an "or where not in" clause to the query. + * + * @return $this */ public function orWhereNotIn(ExpressionContract|string $column, mixed $values): static { @@ -1346,6 +1444,8 @@ public function orWhereNotIn(ExpressionContract|string $column, mixed $values): /** * Add a "where in raw" clause for integer values to the query. + * + * @return $this */ public function whereIntegerInRaw(string $column, Arrayable|array $values, string $boolean = 'and', bool $not = false): static { @@ -1368,6 +1468,8 @@ public function whereIntegerInRaw(string $column, Arrayable|array $values, strin /** * Add an "or where in raw" clause for integer values to the query. + * + * @return $this */ public function orWhereIntegerInRaw(string $column, Arrayable|array $values): static { @@ -1376,6 +1478,8 @@ public function orWhereIntegerInRaw(string $column, Arrayable|array $values): st /** * Add a "where not in raw" clause for integer values to the query. + * + * @return $this */ public function whereIntegerNotInRaw(string $column, Arrayable|array $values, string $boolean = 'and'): static { @@ -1384,6 +1488,8 @@ public function whereIntegerNotInRaw(string $column, Arrayable|array $values, st /** * Add an "or where not in raw" clause for integer values to the query. + * + * @return $this */ public function orWhereIntegerNotInRaw(string $column, Arrayable|array $values): static { @@ -1392,6 +1498,8 @@ public function orWhereIntegerNotInRaw(string $column, Arrayable|array $values): /** * Add a "where null" clause to the query. + * + * @return $this */ public function whereNull(string|array|ExpressionContract $columns, string $boolean = 'and', bool $not = false): static { @@ -1406,6 +1514,8 @@ public function whereNull(string|array|ExpressionContract $columns, string $bool /** * Add an "or where null" clause to the query. + * + * @return $this */ public function orWhereNull(string|array|ExpressionContract $column): static { @@ -1414,6 +1524,8 @@ public function orWhereNull(string|array|ExpressionContract $column): static /** * Add a "where not null" clause to the query. + * + * @return $this */ public function whereNotNull(string|array|ExpressionContract $columns, string $boolean = 'and'): static { @@ -1424,6 +1536,7 @@ public function whereNotNull(string|array|ExpressionContract $columns, string $b * Add a "where between" statement to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function whereBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and', bool $not = false): static { @@ -1453,6 +1566,7 @@ public function whereBetween(Closure|self|EloquentBuilder|Relation|ExpressionCon * Add a "where between" statement using columns to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function whereBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and', bool $not = false): static { @@ -1474,6 +1588,7 @@ public function whereBetweenColumns(Closure|self|EloquentBuilder|Relation|Expres * Add an "or where between" statement to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function orWhereBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static { @@ -1482,6 +1597,8 @@ public function orWhereBetween(Closure|self|EloquentBuilder|Relation|ExpressionC /** * Add an "or where between" statement using columns to the query. + * + * @return $this */ public function orWhereBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static { @@ -1492,6 +1609,7 @@ public function orWhereBetweenColumns(Closure|self|EloquentBuilder|Relation|Expr * Add a "where not between" statement to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function whereNotBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and'): static { @@ -1500,6 +1618,8 @@ public function whereNotBetween(Closure|self|EloquentBuilder|Relation|Expression /** * Add a "where not between" statement using columns to the query. + * + * @return $this */ public function whereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and'): static { @@ -1510,6 +1630,7 @@ public function whereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|Exp * Add an "or where not between" statement to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function orWhereNotBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static { @@ -1518,6 +1639,8 @@ public function orWhereNotBetween(Closure|self|EloquentBuilder|Relation|Expressi /** * Add an "or where not between" statement using columns to the query. + * + * @return $this */ public function orWhereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static { @@ -1528,6 +1651,7 @@ public function orWhereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|E * Add a "where between columns" statement using a value to the query. * * @param array{ExpressionContract|string, ExpressionContract|string} $columns + * @return $this */ public function whereValueBetween(mixed $value, array $columns, string $boolean = 'and', bool $not = false): static { @@ -1547,6 +1671,7 @@ public function whereValueBetween(mixed $value, array $columns, string $boolean * Add an "or where between columns" statement using a value to the query. * * @param array{ExpressionContract|string, ExpressionContract|string} $columns + * @return $this */ public function orWhereValueBetween(mixed $value, array $columns): static { @@ -1557,6 +1682,7 @@ public function orWhereValueBetween(mixed $value, array $columns): static * Add a "where not between columns" statement using a value to the query. * * @param array{ExpressionContract|string, ExpressionContract|string} $columns + * @return $this */ public function whereValueNotBetween(mixed $value, array $columns, string $boolean = 'and'): static { @@ -1567,6 +1693,7 @@ public function whereValueNotBetween(mixed $value, array $columns, string $boole * Add an "or where not between columns" statement using a value to the query. * * @param array{ExpressionContract|string, ExpressionContract|string} $columns + * @return $this */ public function orWhereValueNotBetween(mixed $value, array $columns): static { @@ -1575,6 +1702,8 @@ public function orWhereValueNotBetween(mixed $value, array $columns): static /** * Add an "or where not null" clause to the query. + * + * @return $this */ public function orWhereNotNull(array|ExpressionContract|string $column): static { @@ -1583,6 +1712,8 @@ public function orWhereNotNull(array|ExpressionContract|string $column): static /** * Add a "where date" statement to the query. + * + * @return $this */ public function whereDate(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -1610,6 +1741,8 @@ public function whereDate(ExpressionContract|BaseStringable|string $column, mixe /** * Add an "or where date" statement to the query. + * + * @return $this */ public function orWhereDate(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null): static { @@ -1624,6 +1757,8 @@ public function orWhereDate(ExpressionContract|BaseStringable|string $column, mi /** * Add a "where time" statement to the query. + * + * @return $this */ public function whereTime(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -1651,6 +1786,8 @@ public function whereTime(ExpressionContract|BaseStringable|string $column, mixe /** * Add an "or where time" statement to the query. + * + * @return $this */ public function orWhereTime(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null): static { @@ -1665,6 +1802,8 @@ public function orWhereTime(ExpressionContract|BaseStringable|string $column, mi /** * Add a "where day" statement to the query. + * + * @return $this */ public function whereDay(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -1697,6 +1836,8 @@ public function whereDay(ExpressionContract|BaseStringable|string $column, mixed /** * Add an "or where day" statement to the query. + * + * @return $this */ public function orWhereDay(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null): static { @@ -1711,6 +1852,8 @@ public function orWhereDay(ExpressionContract|BaseStringable|string $column, mix /** * Add a "where month" statement to the query. + * + * @return $this */ public function whereMonth(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -1743,6 +1886,8 @@ public function whereMonth(ExpressionContract|BaseStringable|string $column, mix /** * Add an "or where month" statement to the query. + * + * @return $this */ public function orWhereMonth(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null): static { @@ -1757,6 +1902,8 @@ public function orWhereMonth(ExpressionContract|BaseStringable|string $column, m /** * Add a "where year" statement to the query. + * + * @return $this */ public function whereYear(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -1784,6 +1931,8 @@ public function whereYear(ExpressionContract|BaseStringable|string $column, mixe /** * Add an "or where year" statement to the query. + * + * @return $this */ public function orWhereYear(ExpressionContract|BaseStringable|string $column, mixed $operator, mixed $value = null): static { @@ -1798,6 +1947,8 @@ public function orWhereYear(ExpressionContract|BaseStringable|string $column, mi /** * Add a date based (year, month, day, time) statement to the query. + * + * @return $this */ protected function addDateBasedWhere(string $type, ExpressionContract|BaseStringable|string $column, string $operator, mixed $value, string $boolean = 'and'): static { @@ -1815,6 +1966,8 @@ protected function addDateBasedWhere(string $type, ExpressionContract|BaseString /** * Add a nested "where" statement to the query. + * + * @return $this */ public function whereNested(Closure $callback, string $boolean = 'and'): static { @@ -1839,6 +1992,8 @@ public function forNestedWhere(): static /** * Add another query builder as a nested where to the query builder. + * + * @return $this */ public function addNestedWhereQuery(self $query, string $boolean = 'and'): static { @@ -1857,6 +2012,7 @@ public function addNestedWhereQuery(self $query, string $boolean = 'and'): stati * Add a full sub-select to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *> $callback + * @return $this * * @throws InvalidArgumentException */ @@ -1888,6 +2044,7 @@ protected function whereSub(ExpressionContract|string $column, string $operator, * Add an "exists" clause to the query. * * @param Closure|self|EloquentBuilder<*> $callback + * @return $this * * @throws InvalidArgumentException */ @@ -1911,6 +2068,7 @@ public function whereExists(Closure|self|EloquentBuilder $callback, string $bool * Add an "or where exists" clause to the query. * * @param Closure|self|EloquentBuilder<*> $callback + * @return $this * * @throws InvalidArgumentException */ @@ -1923,6 +2081,7 @@ public function orWhereExists(Closure|self|EloquentBuilder $callback, bool $not * Add a "where not exists" clause to the query. * * @param Closure|self|EloquentBuilder<*> $callback + * @return $this * * @throws InvalidArgumentException */ @@ -1935,6 +2094,7 @@ public function whereNotExists(Closure|self|EloquentBuilder $callback, string $b * Add an "or where not exists" clause to the query. * * @param Closure|self|EloquentBuilder<*> $callback + * @return $this * * @throws InvalidArgumentException */ @@ -1946,6 +2106,8 @@ public function orWhereNotExists(Closure|self|EloquentBuilder $callback): static /** * Add an "exists" clause to the query. * + * @return $this + * * @throws InvalidArgumentException */ public function addWhereExistsQuery(self $query, string $boolean = 'and', bool $not = false): static @@ -1964,6 +2126,8 @@ public function addWhereExistsQuery(self $query, string $boolean = 'and', bool $ /** * Add a where condition using row values. * + * @return $this + * * @throws InvalidArgumentException */ public function whereRowValues(array $columns, string $operator, array $values, string $boolean = 'and'): static @@ -1987,6 +2151,8 @@ public function whereRowValues(array $columns, string $operator, array $values, /** * Adds an or where condition using row values. + * + * @return $this */ public function orWhereRowValues(array $columns, string $operator, array $values): static { @@ -1995,6 +2161,8 @@ public function orWhereRowValues(array $columns, string $operator, array $values /** * Add a "where JSON contains" clause to the query. + * + * @return $this */ public function whereJsonContains(string $column, mixed $value, string $boolean = 'and', bool $not = false): static { @@ -2011,6 +2179,8 @@ public function whereJsonContains(string $column, mixed $value, string $boolean /** * Add an "or where JSON contains" clause to the query. + * + * @return $this */ public function orWhereJsonContains(string $column, mixed $value): static { @@ -2019,6 +2189,8 @@ public function orWhereJsonContains(string $column, mixed $value): static /** * Add a "where JSON not contains" clause to the query. + * + * @return $this */ public function whereJsonDoesntContain(string $column, mixed $value, string $boolean = 'and'): static { @@ -2027,6 +2199,8 @@ public function whereJsonDoesntContain(string $column, mixed $value, string $boo /** * Add an "or where JSON not contains" clause to the query. + * + * @return $this */ public function orWhereJsonDoesntContain(string $column, mixed $value): static { @@ -2035,6 +2209,8 @@ public function orWhereJsonDoesntContain(string $column, mixed $value): static /** * Add a "where JSON overlaps" clause to the query. + * + * @return $this */ public function whereJsonOverlaps(string $column, mixed $value, string $boolean = 'and', bool $not = false): static { @@ -2051,6 +2227,8 @@ public function whereJsonOverlaps(string $column, mixed $value, string $boolean /** * Add an "or where JSON overlaps" clause to the query. + * + * @return $this */ public function orWhereJsonOverlaps(string $column, mixed $value): static { @@ -2059,6 +2237,8 @@ public function orWhereJsonOverlaps(string $column, mixed $value): static /** * Add a "where JSON not overlap" clause to the query. + * + * @return $this */ public function whereJsonDoesntOverlap(string $column, mixed $value, string $boolean = 'and'): static { @@ -2067,6 +2247,8 @@ public function whereJsonDoesntOverlap(string $column, mixed $value, string $boo /** * Add an "or where JSON not overlap" clause to the query. + * + * @return $this */ public function orWhereJsonDoesntOverlap(string $column, mixed $value): static { @@ -2075,6 +2257,8 @@ public function orWhereJsonDoesntOverlap(string $column, mixed $value): static /** * Add a clause that determines if a JSON path exists to the query. + * + * @return $this */ public function whereJsonContainsKey(string $column, string $boolean = 'and', bool $not = false): static { @@ -2087,6 +2271,8 @@ public function whereJsonContainsKey(string $column, string $boolean = 'and', bo /** * Add an "or" clause that determines if a JSON path exists to the query. + * + * @return $this */ public function orWhereJsonContainsKey(string $column): static { @@ -2095,6 +2281,8 @@ public function orWhereJsonContainsKey(string $column): static /** * Add a clause that determines if a JSON path does not exist to the query. + * + * @return $this */ public function whereJsonDoesntContainKey(string $column, string $boolean = 'and'): static { @@ -2103,6 +2291,8 @@ public function whereJsonDoesntContainKey(string $column, string $boolean = 'and /** * Add an "or" clause that determines if a JSON path does not exist to the query. + * + * @return $this */ public function orWhereJsonDoesntContainKey(string $column): static { @@ -2111,6 +2301,8 @@ public function orWhereJsonDoesntContainKey(string $column): static /** * Add a "where JSON length" clause to the query. + * + * @return $this */ public function whereJsonLength(string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static { @@ -2140,6 +2332,8 @@ public function whereJsonLength(string $column, mixed $operator, mixed $value = /** * Add an "or where JSON length" clause to the query. + * + * @return $this */ public function orWhereJsonLength(string $column, mixed $operator, mixed $value = null): static { @@ -2154,6 +2348,8 @@ public function orWhereJsonLength(string $column, mixed $operator, mixed $value /** * Handles dynamic "where" clauses to the query. + * + * @return $this */ public function dynamicWhere(string $method, array $parameters): static { @@ -2209,6 +2405,8 @@ protected function addDynamic(string $segment, string $connector, array $paramet /** * Add a "where fulltext" clause to the query. + * + * @return $this */ public function whereFullText(string|array $columns, string $value, array $options = [], string $boolean = 'and'): static { @@ -2225,6 +2423,8 @@ public function whereFullText(string|array $columns, string $value, array $optio /** * Add an "or where fulltext" clause to the query. + * + * @return $this */ public function orWhereFullText(string|array $columns, string $value, array $options = []): static { @@ -2235,6 +2435,7 @@ public function orWhereFullText(string|array $columns, string $value, array $opt * Add a "where" clause to the query for multiple columns with "and" conditions between them. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function whereAll(array $columns, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -2257,6 +2458,7 @@ public function whereAll(array $columns, mixed $operator = null, mixed $value = * Add an "or where" clause to the query for multiple columns with "and" conditions between them. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function orWhereAll(array $columns, mixed $operator = null, mixed $value = null): static { @@ -2267,6 +2469,7 @@ public function orWhereAll(array $columns, mixed $operator = null, mixed $value * Add a "where" clause to the query for multiple columns with "or" conditions between them. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function whereAny(array $columns, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -2289,6 +2492,7 @@ public function whereAny(array $columns, mixed $operator = null, mixed $value = * Add an "or where" clause to the query for multiple columns with "or" conditions between them. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function orWhereAny(array $columns, mixed $operator = null, mixed $value = null): static { @@ -2299,6 +2503,7 @@ public function orWhereAny(array $columns, mixed $operator = null, mixed $value * Add a "where not" clause to the query for multiple columns where none of the conditions should be true. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function whereNone(array $columns, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { @@ -2309,6 +2514,7 @@ public function whereNone(array $columns, mixed $operator = null, mixed $value = * Add an "or where not" clause to the query for multiple columns where none of the conditions should be true. * * @param array|Relation<*, *, *>|ExpressionContract|string> $columns + * @return $this */ public function orWhereNone(array $columns, mixed $operator = null, mixed $value = null): static { @@ -2317,6 +2523,8 @@ public function orWhereNone(array $columns, mixed $operator = null, mixed $value /** * Add a "group by" clause to the query. + * + * @return $this */ public function groupBy(array|ExpressionContract|string ...$groups): static { @@ -2332,6 +2540,8 @@ public function groupBy(array|ExpressionContract|string ...$groups): static /** * Add a raw "groupBy" clause to the query. + * + * @return $this */ public function groupByRaw(string $sql, array $bindings = []): static { @@ -2344,6 +2554,8 @@ public function groupByRaw(string $sql, array $bindings = []): static /** * Add a "having" clause to the query. + * + * @return $this */ public function having( ExpressionContract|Closure|string $column, @@ -2396,6 +2608,8 @@ public function having( /** * Add an "or having" clause to the query. + * + * @return $this */ public function orHaving( ExpressionContract|Closure|string $column, @@ -2413,6 +2627,8 @@ public function orHaving( /** * Add a nested "having" statement to the query. + * + * @return $this */ public function havingNested(Closure $callback, string $boolean = 'and'): static { @@ -2423,6 +2639,8 @@ public function havingNested(Closure $callback, string $boolean = 'and'): static /** * Add another query builder as a nested having to the query builder. + * + * @return $this */ public function addNestedHavingQuery(self $query, string $boolean = 'and'): static { @@ -2439,6 +2657,8 @@ public function addNestedHavingQuery(self $query, string $boolean = 'and'): stat /** * Add a "having null" clause to the query. + * + * @return $this */ public function havingNull(array|string $columns, string $boolean = 'and', bool $not = false): static { @@ -2453,6 +2673,8 @@ public function havingNull(array|string $columns, string $boolean = 'and', bool /** * Add an "or having null" clause to the query. + * + * @return $this */ public function orHavingNull(string $column): static { @@ -2461,6 +2683,8 @@ public function orHavingNull(string $column): static /** * Add a "having not null" clause to the query. + * + * @return $this */ public function havingNotNull(array|string $columns, string $boolean = 'and'): static { @@ -2469,6 +2693,8 @@ public function havingNotNull(array|string $columns, string $boolean = 'and'): s /** * Add an "or having not null" clause to the query. + * + * @return $this */ public function orHavingNotNull(string $column): static { @@ -2477,6 +2703,8 @@ public function orHavingNotNull(string $column): static /** * Add a "having between" clause to the query. + * + * @return $this */ public function havingBetween(string $column, iterable $values, string $boolean = 'and', bool $not = false): static { @@ -2497,6 +2725,8 @@ public function havingBetween(string $column, iterable $values, string $boolean /** * Add a "having not between" clause to the query. + * + * @return $this */ public function havingNotBetween(string $column, iterable $values, string $boolean = 'and'): static { @@ -2505,6 +2735,8 @@ public function havingNotBetween(string $column, iterable $values, string $boole /** * Add an "or having between" clause to the query. + * + * @return $this */ public function orHavingBetween(string $column, iterable $values): static { @@ -2513,6 +2745,8 @@ public function orHavingBetween(string $column, iterable $values): static /** * Add an "or having not between" clause to the query. + * + * @return $this */ public function orHavingNotBetween(string $column, iterable $values): static { @@ -2542,6 +2776,8 @@ protected function resolveDatePeriodBounds(DatePeriod $period): array /** * Add a raw "having" clause to the query. + * + * @return $this */ public function havingRaw(string $sql, array $bindings = [], string $boolean = 'and'): static { @@ -2556,6 +2792,8 @@ public function havingRaw(string $sql, array $bindings = [], string $boolean = ' /** * Add a raw "or having" clause to the query. + * + * @return $this */ public function orHavingRaw(string $sql, array $bindings = []): static { @@ -2567,6 +2805,7 @@ public function orHavingRaw(string $sql, array $bindings = []): static * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column * @param 'asc'|'desc'|SortDirection $direction + * @return $this * * @throws InvalidArgumentException */ @@ -2602,6 +2841,7 @@ public function orderBy(Closure|self|EloquentBuilder|Relation|ExpressionContract * Add a descending "order by" clause to the query. * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|string $column + * @return $this */ public function orderByDesc(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column): static { @@ -2610,6 +2850,8 @@ public function orderByDesc(Closure|self|EloquentBuilder|Relation|ExpressionCont /** * Add an "order by" clause for a timestamp to the query. + * + * @return $this */ public function latest(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column = 'created_at'): static { @@ -2618,6 +2860,8 @@ public function latest(Closure|self|EloquentBuilder|Relation|ExpressionContract| /** * Add an "order by" clause for a timestamp to the query. + * + * @return $this */ public function oldest(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column = 'created_at'): static { @@ -2628,6 +2872,7 @@ public function oldest(Closure|self|EloquentBuilder|Relation|ExpressionContract| * Add a vector-distance "order by" clause to the query. * * @param array|Arrayable>|Collection|string $vector + * @return $this * * @throws JsonException */ @@ -2636,7 +2881,7 @@ public function orderByVectorDistance(ExpressionContract|string $column, Collect $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); } $this->addBinding( @@ -2659,6 +2904,8 @@ public function orderByVectorDistance(ExpressionContract|string $column, Collect /** * Put the query's results in random order. + * + * @return $this */ public function inRandomOrder(string|int $seed = ''): static { @@ -2669,6 +2916,7 @@ public function inRandomOrder(string|int $seed = ''): static * Add an "order by" clause to order results by a given sequence of values. * * @param array|Arrayable $values + * @return $this */ public function inOrderOf(ExpressionContract|string $column, Arrayable|array $values): static { @@ -2697,6 +2945,8 @@ public function inOrderOf(ExpressionContract|string $column, Arrayable|array $va /** * Add a raw "order by" clause to the query. + * + * @return $this */ public function orderByRaw(string $sql, mixed $bindings = []): static { @@ -2731,6 +2981,8 @@ protected function addOrderBindings(array $bindings): void /** * Alias to set the "offset" value of the query. + * + * @return $this */ public function skip(?int $value): static { @@ -2739,6 +2991,8 @@ public function skip(?int $value): static /** * Set the "offset" value of the query. + * + * @return $this */ public function offset(?int $value): static { @@ -2751,6 +3005,8 @@ public function offset(?int $value): static /** * Alias to set the "limit" value of the query. + * + * @return $this */ public function take(?int $value): static { @@ -2759,6 +3015,8 @@ public function take(?int $value): static /** * Set the "limit" value of the query. + * + * @return $this */ public function limit(?int $value): static { @@ -2773,6 +3031,8 @@ public function limit(?int $value): static /** * Add a "group limit" clause to the query. + * + * @return $this */ public function groupLimit(int $value, string $column): static { @@ -2785,6 +3045,8 @@ public function groupLimit(int $value, string $column): static /** * Set the limit and offset for a given page. + * + * @return $this */ public function forPage(int $page, int $perPage = 15): static { @@ -2795,6 +3057,7 @@ public function forPage(int $page, int $perPage = 15): static * Constrain the query to the previous "page" of results before a given ID. * * @param null|BinaryParameter|int|resource|string $lastId + * @return $this */ public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $column = 'id'): static { @@ -2828,6 +3091,7 @@ public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $co * Constrain the query to the next "page" of results after a given ID. * * @param null|BinaryParameter|int|resource|string $lastId + * @return $this */ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $column = 'id'): static { @@ -2861,6 +3125,7 @@ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $col * Remove all existing orders and optionally add a new order. * * @param 'asc'|'desc'|SortDirection $direction + * @return $this */ public function reorder(Closure|self|EloquentBuilder|Relation|ExpressionContract|string|null $column = null, SortDirection|string $direction = SortDirection::Ascending): static { @@ -2879,6 +3144,8 @@ public function reorder(Closure|self|EloquentBuilder|Relation|ExpressionContract /** * Add descending "reorder" clause to the query. + * + * @return $this */ public function reorderDesc(Closure|self|EloquentBuilder|Relation|ExpressionContract|string|null $column): static { @@ -2941,6 +3208,7 @@ protected function removeOrdersFor(array $orders, array &$bindings, string $colu * Add a "union" statement to the query. * * @param Closure|self|EloquentBuilder<*> $query + * @return $this * * @throws InvalidArgumentException */ @@ -2967,6 +3235,7 @@ public function union(Closure|self|EloquentBuilder $query, bool $all = false): s * Add a "union all" statement to the query. * * @param Closure|self|EloquentBuilder<*> $query + * @return $this * * @throws InvalidArgumentException */ @@ -2977,6 +3246,8 @@ public function unionAll(Closure|self|EloquentBuilder $query): static /** * Lock the selected rows in the table. + * + * @return $this */ public function lock(string|bool $value = true): static { @@ -2992,6 +3263,8 @@ public function lock(string|bool $value = true): static /** * Lock the selected rows in the table for updating. + * + * @return $this */ public function lockForUpdate(): static { @@ -3000,6 +3273,8 @@ public function lockForUpdate(): static /** * Share lock the selected rows in the table. + * + * @return $this */ public function sharedLock(): static { @@ -3008,6 +3283,8 @@ public function sharedLock(): static /** * Skip rows locked by another transaction when supported by the server. + * + * @return $this */ public function skipLocked(): static { @@ -3023,6 +3300,8 @@ public function skipLocked(): static /** * Set a query execution timeout in seconds. * + * @return $this + * * @throws InvalidArgumentException */ public function timeout(?int $seconds): static @@ -3038,6 +3317,8 @@ public function timeout(?int $seconds): static /** * Register a closure to be invoked before the query is executed. + * + * @return $this */ public function beforeQuery(callable $callback): static { @@ -3062,6 +3343,7 @@ public function applyBeforeQueryCallbacks(): void * Register a closure to be invoked after the query is executed. * * @param Closure(Collection): (Collection|void) $callback + * @return $this */ public function afterQuery(Closure $callback): static { @@ -3274,7 +3556,9 @@ protected function withoutGroupLimitKeys(Collection $items): Collection /** * Paginate the given query into a simple paginator. * + * @param (Closure(int): int)|int $perPage * @param array|ExpressionContract|string $columns + * @param null|(Closure(): ?int)|int $total */ public function paginate( int|Closure $perPage = 15, @@ -3459,6 +3743,7 @@ public function restoreKeysetOrdersFrom(self $source): bool * Sort an ID iteration by its paging column before any other order. * * @internal + * @return $this */ public function prioritizeKeysetOrders(bool $limited = false): static { @@ -3512,6 +3797,7 @@ public function ensureLimitedIterationOrdersById(string $column, bool $descendin * * @param list $orders * @param array $bindings + * @return $this * @internal */ public function addCursorPaginationConditions(array $orders, array $bindings): static @@ -3910,6 +4196,8 @@ protected function applyUnionKeysetConditions(self $root, array $conditions, arr /** * Append comparisons using the member's source columns or isolated output names. + * + * @return $this */ protected function appendKeysetConditions(array $conditions, array $mapping = [], bool $output = false): static { @@ -4359,6 +4647,8 @@ public function max(ExpressionContract|string $column): mixed /** * Retrieve the sum of the values of a given column. + * + * @return float|int|string */ public function sum(ExpressionContract|string $column): mixed { @@ -4369,6 +4659,8 @@ public function sum(ExpressionContract|string $column): mixed /** * Retrieve the average of the values of a given column. + * + * @return null|float|int|string */ public function avg(ExpressionContract|string $column): mixed { @@ -4377,6 +4669,8 @@ public function avg(ExpressionContract|string $column): mixed /** * Alias for the "avg" method. + * + * @return null|float|int|string */ public function average(ExpressionContract|string $column): mixed { @@ -4441,6 +4735,7 @@ public function numericAggregate(string $function, array $columns = ['*']): floa * Set the aggregate property without running the query. * * @param array $columns + * @return $this */ protected function setAggregate(string $function, array $columns): static { @@ -5018,6 +5313,7 @@ public function getRawBindings(): array * * @param list $bindings * @param TBindingType $type + * @return $this * * @throws InvalidArgumentException */ @@ -5036,6 +5332,7 @@ public function setBindings(array $bindings, string $type = 'where'): static * Add a binding to the query. * * @param TBindingType $type + * @return $this * * @throws InvalidArgumentException */ @@ -5071,6 +5368,8 @@ public function castBinding(mixed $value): mixed /** * Merge an array of bindings into our bindings. + * + * @return $this */ public function mergeBindings(self $query): static { @@ -5151,6 +5450,8 @@ public function getGrammar(): Grammar /** * Use the write connection when executing the query. + * + * @return $this */ public function useWritePdo(): static { @@ -5251,6 +5552,8 @@ public function cloneWithoutBindings(array $except): static /** * Dump the current SQL and bindings. + * + * @return $this */ public function dump(mixed ...$args): static { @@ -5265,6 +5568,8 @@ public function dump(mixed ...$args): static /** * Dump the raw current SQL with embedded bindings. + * + * @return $this */ public function dumpRawSql(): static { diff --git a/src/database/src/Query/JoinClause.php b/src/database/src/Query/JoinClause.php index 0bc5f78b77..0e109f66dc 100644 --- a/src/database/src/Query/JoinClause.php +++ b/src/database/src/Query/JoinClause.php @@ -75,6 +75,8 @@ public function __construct(Builder $parentQuery, string $type, ExpressionContra * * on `contacts`.`user_id` = `users`.`id` and `contacts`.`info_id` = `info`.`id` * + * @return $this + * * @throws InvalidArgumentException */ public function on( diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index 3f85db2e46..9a57451d21 100755 --- a/src/database/src/Schema/Blueprint.php +++ b/src/database/src/Schema/Blueprint.php @@ -1707,6 +1707,8 @@ public function after(string $column, Closure $callback): void /** * Remove a column from the schema blueprint. + * + * @return $this */ public function removeColumn(string $name): static { diff --git a/src/database/src/Schema/ForeignKeyDefinition.php b/src/database/src/Schema/ForeignKeyDefinition.php index 394635e7bb..1d5d7b2e43 100644 --- a/src/database/src/Schema/ForeignKeyDefinition.php +++ b/src/database/src/Schema/ForeignKeyDefinition.php @@ -7,19 +7,21 @@ use Hypervel\Support\Fluent; /** - * @method ForeignKeyDefinition deferrable(bool $value = true) Set the foreign key as deferrable (PostgreSQL) - * @method ForeignKeyDefinition initiallyImmediate(bool $value = true) Set the default time to check the constraint (PostgreSQL) - * @method ForeignKeyDefinition inplace(bool $value = true) Specify that algorithm=inplace should be used for the foreign key operation, which requires foreign key checks to be disabled (MySQL) - * @method ForeignKeyDefinition lock(('default'|'exclusive'|'none'|'shared') $value) Specify the DDL lock mode for the foreign key operation (MySQL) - * @method ForeignKeyDefinition on(string $table) Specify the referenced table - * @method ForeignKeyDefinition onDelete(string $action) Add an ON DELETE action - * @method ForeignKeyDefinition onUpdate(string $action) Add an ON UPDATE action - * @method ForeignKeyDefinition references(string|string[] $columns) Specify the referenced column(s) + * @method $this deferrable(bool $value = true) Set the foreign key as deferrable (PostgreSQL) + * @method $this initiallyImmediate(bool $value = true) Set the default time to check the constraint (PostgreSQL) + * @method $this inplace(bool $value = true) Specify that algorithm=inplace should be used for the foreign key operation, which requires foreign key checks to be disabled (MySQL) + * @method $this lock(('default'|'exclusive'|'none'|'shared') $value) Specify the DDL lock mode for the foreign key operation (MySQL) + * @method $this on(string $table) Specify the referenced table + * @method $this onDelete(string $action) Add an ON DELETE action + * @method $this onUpdate(string $action) Add an ON UPDATE action + * @method $this references(string|string[] $columns) Specify the referenced column(s) */ class ForeignKeyDefinition extends Fluent { /** * Indicate that updates should cascade. + * + * @return $this */ public function cascadeOnUpdate(): self { @@ -28,6 +30,8 @@ public function cascadeOnUpdate(): self /** * Indicate that updates should be restricted. + * + * @return $this */ public function restrictOnUpdate(): self { @@ -36,6 +40,8 @@ public function restrictOnUpdate(): self /** * Indicate that updates should set the foreign key value to null. + * + * @return $this */ public function nullOnUpdate(): self { @@ -44,6 +50,8 @@ public function nullOnUpdate(): self /** * Indicate that updates should have "no action". + * + * @return $this */ public function noActionOnUpdate(): self { @@ -52,6 +60,8 @@ public function noActionOnUpdate(): self /** * Indicate that deletes should cascade. + * + * @return $this */ public function cascadeOnDelete(): self { @@ -60,6 +70,8 @@ public function cascadeOnDelete(): self /** * Indicate that deletes should be restricted. + * + * @return $this */ public function restrictOnDelete(): self { @@ -68,6 +80,8 @@ public function restrictOnDelete(): self /** * Indicate that deletes should set the foreign key value to null. + * + * @return $this */ public function nullOnDelete(): self { @@ -76,6 +90,8 @@ public function nullOnDelete(): self /** * Indicate that deletes should have "no action". + * + * @return $this */ public function noActionOnDelete(): self { diff --git a/src/database/src/Schema/SchemaState.php b/src/database/src/Schema/SchemaState.php index 8f3f018969..4edfef5526 100644 --- a/src/database/src/Schema/SchemaState.php +++ b/src/database/src/Schema/SchemaState.php @@ -102,6 +102,8 @@ protected function getMigrationTable(): string /** * Specify the name of the application's migration table. + * + * @return $this */ public function withMigrationTable(?string $table): static { @@ -112,6 +114,8 @@ public function withMigrationTable(?string $table): static /** * Specify the callback that should be used to handle process output. + * + * @return $this */ public function handleOutputUsing(callable $output): static { diff --git a/src/database/src/Seeder.php b/src/database/src/Seeder.php index be25fac6aa..b507cbba92 100755 --- a/src/database/src/Seeder.php +++ b/src/database/src/Seeder.php @@ -40,6 +40,7 @@ abstract class Seeder implements Transient * Run the given seeder class. * * @param array>|class-string $class + * @return $this */ public function call(array|string $class, bool $silent = false, array $parameters = []): static { @@ -147,6 +148,8 @@ protected function resolve(string $class): Seeder /** * Set the IoC container instance. + * + * @return $this */ public function setContainer(Container $container): static { @@ -157,6 +160,8 @@ public function setContainer(Container $container): static /** * Set the console command instance. + * + * @return $this */ public function setCommand(Command $command): static { diff --git a/src/database/src/UniqueConstraintViolationException.php b/src/database/src/UniqueConstraintViolationException.php index 75112882d8..9ef6c68a1e 100644 --- a/src/database/src/UniqueConstraintViolationException.php +++ b/src/database/src/UniqueConstraintViolationException.php @@ -20,6 +20,8 @@ class UniqueConstraintViolationException extends QueryException /** * Set the unique index which caused the violation. + * + * @return $this */ public function setIndex(?string $index): self { @@ -32,6 +34,7 @@ public function setIndex(?string $index): self * Set the columns that caused the violation. * * @param list $columns + * @return $this */ public function setColumns(array $columns): self { diff --git a/src/di/src/Aop/ProxyCallVisitor.php b/src/di/src/Aop/ProxyCallVisitor.php index b5b1fedc85..8570f314c8 100644 --- a/src/di/src/Aop/ProxyCallVisitor.php +++ b/src/di/src/Aop/ProxyCallVisitor.php @@ -5,6 +5,7 @@ namespace Hypervel\Di\Aop; use Hypervel\Di\Exceptions\InvalidDefinitionException; +use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\ArrayItem; @@ -36,7 +37,6 @@ use PhpParser\Node\Scalar\MagicConst\Trait_ as MagicConstTrait; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; -use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Expression; @@ -246,7 +246,7 @@ private function rewriteMethod(ClassMethod $node): array { $helper = clone $node; $helper->name = new Identifier($this->helperMethodName); - $helper->flags = ($node->flags & Class_::MODIFIER_STATIC) | Class_::MODIFIER_PRIVATE; + $helper->flags = ($node->flags & Modifiers::STATIC) | Modifiers::PRIVATE; $helper->attrGroups = []; $helper->setAttribute('comments', []); $helper->params = $this->buildHelperParameters($node->params); diff --git a/src/docs/ai-sdk.md b/src/docs/ai-sdk.md index d3c97d4456..9abf8aa327 100644 --- a/src/docs/ai-sdk.md +++ b/src/docs/ai-sdk.md @@ -1512,8 +1512,8 @@ Although your tool's schema constrains the arguments a model may provide, you ma public function handle(Request $request): Stringable|string { $validated = $request->validate([ - 'city' => 'required|string', - 'days' => 'required|integer|max:7', + 'city' => ['required', 'string'], + 'days' => ['required', 'integer', 'max:7'], ]); return $this->forecast($validated['city'], $validated['days']); diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 87c3378ec7..5f146c25d9 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -1115,7 +1115,7 @@ class UserData extends Data } ``` -There is no need to add `Required` or `StringType` here; they are inferred from the property declarations. Attributes take precedence over inferred rules of the same type. You may also use `#[Rule('required|string')]` or `#[Rule(['required', 'string'])]` for ordinary rule syntax. See the [validation rule reference](/docs/{{version}}/validation#available-validation-rules) for rule behavior. +There is no need to add `Required` or `StringType` here; they are inferred from the property declarations. Attributes take precedence over inferred rules of the same type. You may also use ordinary rule syntax with `#[Rule('required', 'string')]` or `#[Rule(['required', 'string'])]`. See the [validation rule reference](/docs/{{version}}/validation#available-validation-rules) for rule behavior. To skip validation for one property, add `#[WithoutValidation]`. Its input is still used during creation, so use this only when that value does not need validation. diff --git a/src/docs/database.md b/src/docs/database.md index f1e99be372..dee780aa5a 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -727,11 +727,7 @@ The pool retains modified sessions when the standard host, port and write settin ### Static Analysis -The Hypervel database package includes a PHPStan extension that understands named scopes, soft deleting query methods such as `withTrashed` and `restore`, and query methods forwarded through Eloquent models, builders, and relationships. See the [static analysis setup instructions](/docs/{{version}}/installation#static-analysis) to enable it in your application. - -A scope that declares no return type, or declares `void`, `null`, or the query builder, stays chainable. Declaring a broader type such as `mixed` or `object` tells the analyzer the scope may return something else, so that type is preserved. When a scope declares a union containing the query builder, such as `Builder|int`, the builder becomes the chainable receiver and the remaining types are kept. - -Configure a [custom Eloquent builder](/docs/{{version}}/eloquent#custom-eloquent-builders) on the model, for example with `#[UseEloquentBuilder(YourBuilder::class)]`, then add the `HasBuilder` trait with `@use HasBuilder>` for static typing. The extension follows the model's declared `query()` return type, including through relationships. If your Eloquent builder also uses a custom query builder, declare that type on `getQuery()`. Forwarded methods retain the Eloquent builder or relationship when they are chainable, while custom Eloquent terminal methods retain their result types. Query builders declaring `TKey` and `TValue` templates preserve model-valued callback signatures during forwarding; direct `getQuery()` and `toBase()` calls keep their raw-row types. +The `hypervel/phpstan` extension understands local scopes, soft deleting query methods such as `withTrashed` and `restore`, custom Eloquent builders, and query methods forwarded through Eloquent models, builders, and relationships. See the [static analysis documentation](/docs/{{version}}/static-analysis) to enable it in your application. ## Running SQL Queries diff --git a/src/docs/documentation.md b/src/docs/documentation.md index 7dbc32ca5e..a0db649026 100644 --- a/src/docs/documentation.md +++ b/src/docs/documentation.md @@ -100,6 +100,7 @@ - [Console Tests](/docs/{{version}}/console-tests) - [Database](/docs/{{version}}/database-testing) - [Mocking](/docs/{{version}}/mocking) + - [Static Analysis](/docs/{{version}}/static-analysis) - ## Packages - [API Client](/docs/{{version}}/api-client) - [Doctor](/docs/{{version}}/doctor) diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 4bcf055b5c..56c898297f 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -2406,6 +2406,14 @@ $users = User::where('vip', true)->get(); return $users->withRelationshipAutoloading(); ``` +When automatic eager loading is enabled globally, you may disable it for the models retrieved or made within a closure by invoking the `withoutAutomaticallyEagerLoadingRelationships` method. Like `withoutEvents`, it only affects the current coroutine: + +```php +$users = Model::withoutAutomaticallyEagerLoadingRelationships(function () { + return User::where('vip', true)->get(); +}); +``` + ### Preventing Lazy Loading diff --git a/src/docs/events.md b/src/docs/events.md index 0f91278a2e..72305426e0 100644 --- a/src/docs/events.md +++ b/src/docs/events.md @@ -618,7 +618,7 @@ class AcquireProductKey implements ShouldQueue, ShouldBeUnique In the example above, the `AcquireProductKey` listener is unique. So, the listener will not be queued if another instance of the listener is already on the queue and has not finished processing. This applies to all instances of the listener, regardless of which license triggered the event. -In certain cases, you may want to define a specific "key" that makes the listener unique or you may want to specify a timeout beyond which the listener no longer stays unique. To accomplish this, you may define `uniqueId` and `uniqueFor` properties or methods on your listener class. The methods receive the event instance, allowing you to use event data to construct the return value: +In certain cases, you may want to define a specific "key" that makes the listener unique or you may want to specify a timeout beyond which the listener no longer stays unique. To accomplish this, you may define a `uniqueId` method and use the `UniqueFor` attribute on your listener class, or define a `uniqueFor` method when the timeout depends on the event. The methods receive the event instance, allowing you to use event data to construct the return value: ```php ### Static Analysis -Head includes editor declarations for its route macros and a PHPStan extension for checking their named arguments and fluent return types. PHPStan's extension installer discovers the extension automatically. Without extension discovery, include it in your PHPStan configuration: - -```neon -includes: - - vendor/hypervel/head/extension.neon -``` +Head includes editor declarations for its route macros. The [`hypervel/phpstan`](/docs/{{version}}/static-analysis) extension checks their named arguments and fluent return types. diff --git a/src/docs/helpers.md b/src/docs/helpers.md index 1bf2dd09d9..8e57afb769 100644 --- a/src/docs/helpers.md +++ b/src/docs/helpers.md @@ -3540,6 +3540,8 @@ By default, deferred functions will only be executed if the HTTP response, Artis defer(fn () => Metrics::reportOrder($order))->always(); ``` +Deferred functions, including `always` ones, do not run when coroutine cancellation stops the work, such as a queued job killed by its timeout. + > [!WARNING] > Hypervel runs on Swoole, which exposes its own global `defer` function that conflicts with Hypervel's `defer` helper. Always call Hypervel's `defer` helper explicitly by namespace: `use function Hypervel\Support\defer;` diff --git a/src/docs/installation.md b/src/docs/installation.md index 32bb5efa98..6842a7e581 100644 --- a/src/docs/installation.md +++ b/src/docs/installation.md @@ -219,23 +219,7 @@ Hypervel's application skeleton includes the `swoole/ide-helper` package in deve ### Static Analysis -The application skeleton includes PHPStan and `phpstan/extension-installer`, which loads Hypervel's extensions automatically. Run `composer analyse` to check your application. The database extension understands [Eloquent scopes and forwarded query methods](/docs/{{version}}/database#static-analysis), while the Foundation extension distinguishes factory calls such as `response()` from calls that create a response, such as `response(status: 204)`. If your application uses [Scout](/docs/{{version}}/scout), its extension understands the `searchable` and `unsearchable` query methods. - -For an existing application, install the extension installer and allow its Composer plugin when prompted: - -```shell -composer require --dev phpstan/extension-installer -``` - -If you prefer to configure extensions manually, add these entries to your `phpstan.neon` file instead: - -```neon -includes: - - vendor/hypervel/database/extension.neon - - vendor/hypervel/foundation/extension.neon -``` - -If your application uses Scout, also include `vendor/hypervel/scout/extension.neon`. +The application skeleton includes PHPStan with the `hypervel/phpstan` extension, which boots your application so PHPStan understands its models, container services, configuration and other framework features. Run `composer analyse` to check your application. To add the extension to an existing application, or to learn about its checks and configuration, see the [static analysis documentation](/docs/{{version}}/static-analysis). ## Next Steps diff --git a/src/docs/lifecycle.md b/src/docs/lifecycle.md index ce40a5fb51..fb78c2ccc7 100644 --- a/src/docs/lifecycle.md +++ b/src/docs/lifecycle.md @@ -64,6 +64,10 @@ Once the route or controller method returns a response, the response will travel Finally, once the response travels back through the middleware, the HTTP kernel's `handle` method returns the response object to Hypervel's HTTP server. The response is written to the Swoole socket, and then Hypervel runs any terminable middleware and application terminating callbacks. +You may observe the request lifecycle using events in the `Hypervel\HttpServer\Events` namespace: `RequestReceived` before handling, `RequestHandled` when a response is ready, `ResponseSent` after the send attempt, and `RequestTerminated` when the request's coroutine ends. The events expose the request, response, server name and any exception that escaped request handling or occurred during response sending or request termination. + +If coroutine cancellation interrupts the lifecycle, `RequestCanceled` replaces the remaining events. Remaining terminable middleware and deferred callbacks are skipped. The event's `cancellation` property contains the cancellation or a failure carrying it; `exception` preserves any earlier failure. A non-null `response` means the send attempt finished, not necessarily that it succeeded. Reverb's HTTP server and WebSocket server handshakes dispatch the same event. Listening for this event does not stop cancellation from propagating. + ### Console Commands diff --git a/src/docs/mcp.md b/src/docs/mcp.md index 28357115ff..6f1d1108a7 100644 --- a/src/docs/mcp.md +++ b/src/docs/mcp.md @@ -510,7 +510,7 @@ class CurrentWeatherTool extends Tool public function handle(Request $request): Response { $validated = $request->validate([ - 'location' => 'required|string|max:100', + 'location' => ['required', 'string', 'max:100'], 'units' => 'in:celsius,fahrenheit', ]); @@ -958,7 +958,7 @@ class DescribeWeatherPrompt extends Prompt public function handle(Request $request): Response { $validated = $request->validate([ - 'tone' => 'required|string|max:50', + 'tone' => ['required', 'string', 'max:50'], ]); $tone = $validated['tone']; diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 81e0617206..998ed375c2 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -66,7 +66,7 @@ To dump the schema without including rows from the migrations table, use the `-- php artisan schema:dump --without-migration-data ``` -When you execute this command, Hypervel will write a "schema" file to your application's `database/schema` directory. The schema file's name will correspond to the database connection. Now, when you attempt to migrate your database and no other migrations have been executed, Hypervel will first execute the SQL statements in the schema file of the database connection you are using. After executing the schema file's SQL statements, Hypervel will execute any remaining migrations that were not part of the schema dump. +When you execute this command, Hypervel will write a "schema" file to your application's `database/schema` directory. The schema file's name will correspond to the database connection. If the connection defines a [`migrations_connection`](#setting-the-migration-connection), the schema is dumped from that connection and named after it, since that is the connection the file is loaded into. Now, when you attempt to migrate your database and no other migrations have been executed, Hypervel will first execute the SQL statements in the schema file of the database connection you are using. After executing the schema file's SQL statements, Hypervel will execute any remaining migrations that were not part of the schema dump. If your application's tests use a different database connection than the one you typically use during local development, you should ensure you have dumped a schema file using that database connection so that your tests are able to build your database. You may wish to do this after dumping the database connection you typically use during local development: diff --git a/src/docs/nested-set.md b/src/docs/nested-set.md index c10131638d..0bce4c4dad 100644 --- a/src/docs/nested-set.md +++ b/src/docs/nested-set.md @@ -151,17 +151,14 @@ declare(strict_types=1); namespace App\Models; +use Hypervel\Database\Eloquent\Attributes\Fillable; use Hypervel\Database\Eloquent\Model; use Hypervel\NestedSet\HasNode; +#[Fillable(['name', 'parent_id'])] class Category extends Model { use HasNode; - - protected array $fillable = [ - 'name', - 'parent_id', - ]; } ``` @@ -185,7 +182,7 @@ class CategoryQueryBuilder extends QueryBuilder } ``` -For [static analysis](/docs/{{version}}/database#static-analysis), make the builder generic and add the `HasBuilder` trait with its type. Since `HasNode` already defines `newEloquentBuilder`, keep its version when adding the trait: +For [static analysis](/docs/{{version}}/static-analysis#custom-builders-and-collections), make the builder generic and add the `HasBuilder` trait with its type. Since `HasNode` already defines `newEloquentBuilder`, keep its version when adding the trait: ```php use Hypervel\Database\Eloquent\HasBuilder; @@ -994,19 +991,15 @@ declare(strict_types=1); namespace App\Models; +use Hypervel\Database\Eloquent\Attributes\Fillable; use Hypervel\Database\Eloquent\Model; use Hypervel\NestedSet\HasNode; +#[Fillable(['menu_id', 'title', 'parent_id'])] class MenuItem extends Model { use HasNode; - protected array $fillable = [ - 'menu_id', - 'title', - 'parent_id', - ]; - protected function getScopeAttributes(): array { return ['menu_id']; diff --git a/src/docs/opentelemetry.md b/src/docs/opentelemetry.md index 17de22fa92..b059688fc3 100644 --- a/src/docs/opentelemetry.md +++ b/src/docs/opentelemetry.md @@ -177,7 +177,7 @@ Each built-in instrumentation class may be set to `false`, `true`, or an option Omitting a built-in metric does not disable it. Set its name to `false` to disable it individually, or set `metrics` to `false` to disable every metric in that instrumentation. Named entries override only the metrics they name; the others keep their shipped defaults. -A `Swoole\Coroutine\CanceledException` produces no completion span, metric, or event. If cleanup throws another exception while cancellation unwinds, that error is recorded like other failures and propagates with the cancellation. Coroutine-local telemetry state is released when the coroutine ends. +When coroutine cancellation stops an operation, its span ends, its duration is recorded and active-operation counters are updated. A direct `Swoole\Coroutine\CanceledException` is not recorded as an error. Known outcomes, such as a response already sent or a job accepted by its queue, are preserved. Outcomes it never reached, such as an HTTP or gRPC status, exit code or `result` attribute, are omitted. Earlier failures and cleanup failures carrying cancellation are still recorded as errors. The shipped defaults are: @@ -319,7 +319,9 @@ The built-in `background` and `deferred` connections propagate the current conte Jobs dispatched after a database commit are finalized and timed only when the queue driver actually begins enqueueing them. Database and SQS batch operations retain one lifecycle per logical message. Later SQS chunks include the time spent sending earlier chunks because timing begins when the batch is finalized, and a later batch failure completes every still-outstanding message once. -Terminal queue correlation normally uses the exact final encoded payload without decoding it. If another finalizer rewrites that payload afterward, the package performs one terminal decode to recover Hypervel's job UUID. Payloads from custom drivers or `Queue::createPayloadUsing()` callbacks that do not contain a string UUID are accepted, omit `messaging.message.id`, and use exact-payload correlation only. If a later finalizer rewrites such a payload, its producer span remains started but is never ended or exported, and its state remains until coroutine or process exit. A later finalizer that removes or corrupts an existing UUID likewise leaves unmatched state. A long-running non-coroutine custom process that catches and continues after such failures may retain that state until it exits. +If you customize payloads using `Queue::createPayloadUsing()` or a [`JobPayloadFinalizing`](/docs/{{version}}/queues#job-events) listener, preserve the job's string `uuid` so instrumentation can match it after payload changes. Payloads without a string UUID omit `messaging.message.id` and can only be matched by their exact contents. If a later `JobPayloadFinalizing` listener changes such a payload or removes a payload's UUID, its producer span remains unfinished and its state is retained until the coroutine or non-coroutine process exits. + +The `messaging.client.sent.messages` counter counts messages the producer attempted to send. A job whose enqueue attempt failed or was canceled before it reached the queue backend still ends its producer span and records its duration, but is not counted as sent. When using the [transactional outbox](/docs/{{version}}/queue-outbox), the producer span ends when the job is stored in the outbox. The relay preserves the job's trace context without creating another producer span. The following metrics are enabled by default and record delivery separately for each queue connection's batch: @@ -367,7 +369,7 @@ It adds an event to the current recording span without inspecting event payloads View spans include only the view name. View data, rendered output, and filesystem paths are excluded. Scout spans cover the actual search-engine `search`, `paginate`, `update`, `delete`, `flush`, and filtered-delete operation; query strings, pagination values, model IDs, and index payloads are excluded. Engines resolved through `EngineManager` are observed automatically. A directly constructed raw engine is not. -When `hypervel/grpc` is installed, one span covers a complete logical RPC across retries, backoff, and streaming. Message bodies and metadata values are excluded. Every non-OK client status is classified as an error. Server spans follow the narrower RPC server convention, so application-result statuses such as `INVALID_ARGUMENT` do not become errors merely because they are non-OK. Exception records reported within an RPC retain the bounded `rpc` origin. Explicit `Call::cancel()` completes a client span as `CANCELLED`; runtime coroutine cancellation does not emit a false completion. Client endpoint attributes use the known configured address and port. Server metric address attributes remain off unless `server_metric_address` is enabled. +When `hypervel/grpc` is installed, one span covers a complete logical RPC across retries, backoff, and streaming. Message bodies and metadata values are excluded. Every non-OK client status is classified as an error. Server spans follow the narrower RPC server convention, so application-result statuses such as `INVALID_ARGUMENT` do not become errors merely because they are non-OK. Exception records reported within an RPC retain the bounded `rpc` origin. Explicit `Call::cancel()` completes a client span as `CANCELLED`. When coroutine cancellation stops an RPC, its span ends without an RPC status. A client call released before it finishes ends with the final status it already received, if any. Client endpoint attributes use the known configured address and port. Server metric address attributes remain off unless `server_metric_address` is enabled. WebSocket instrumentation covers each application message and active connection counts without creating connection-lifetime spans. It also covers Reverb's WebSocket handler through the shared Hypervel message boundary. diff --git a/src/docs/passwords.md b/src/docs/passwords.md index 5610047b10..79a63a880b 100644 --- a/src/docs/passwords.md +++ b/src/docs/passwords.md @@ -134,7 +134,7 @@ use Hypervel\Http\Request; use Hypervel\Support\Facades\Password; Route::post('/forgot-password', function (Request $request) { - $request->validate(['email' => 'required|email']); + $request->validate(['email' => ['required', 'email']]); $status = Password::sendResetLink( $request->only('email') @@ -190,8 +190,8 @@ use Hypervel\Support\Str; Route::post('/reset-password', function (Request $request) { $request->validate([ 'token' => 'required', - 'email' => 'required|email', - 'password' => 'required|min:8|confirmed', + 'email' => ['required', 'email'], + 'password' => ['required', 'min:8', 'confirmed'], ]); $status = Password::reset( diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 58a59e2852..051437efa4 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -24,6 +24,7 @@ - [Other API Differences](#other-api-differences) - [Development Processes](#development-processes) - [Doctor](#doctor) + - [Static Analysis](#static-analysis) - [Scheduling](#scheduling) - [Maintenance Mode](#maintenance-mode) - [HTTP Client and Concurrency](#http-client-and-concurrency) @@ -514,6 +515,15 @@ For `artisan dev`, install `@laravel/multiplex` locally when using pnpm or Yarn. Replace `laravel/doctor` with `hypervel/doctor` and the `Laravel\Doctor` namespace with `Hypervel\Doctor`. Rename calls to `EnvironmentMode::fromLaravelEnvironment()` to `fromHypervelEnvironment()`. Remove explicit registrations of `ApplicationTimezoneIsValid`: Hypervel rejects invalid timezones during startup, before Doctor can run. Register custom diagnostics during provider boot, and run programmatic repairs without concurrent application traffic. See [Doctor](/docs/{{version}}/doctor). + +### Static Analysis + +Replace `larastan/larastan` with `hypervel/phpstan`. For manual configuration, replace Larastan's include with `vendor/hypervel/phpstan/extension.neon` and `vendor/phpstan/phpstan-deprecation-rules/rules.neon`. + +Rename ignored identifiers from `larastan.*` to `hypervel.*`, and `rules.modelAppends` to `hypervel.modelAppends`. PHPStan's own identifiers are unchanged. Remove ignores for Octane and deferred provider diagnostics, and remove the unsupported `checkOctaneCompatibility`, `generalizeEnvReturnType` and `redisDriver` parameters. + +Supported parameters retain their names, but several defaults are stricter. The extension also reports ignores without reasons and uses of deprecated APIs. See [Static Analysis](/docs/{{version}}/static-analysis) for setup and configuration. + ### Scheduling diff --git a/src/docs/prompts.md b/src/docs/prompts.md index 3cb4a48a5d..3f8ceef1c7 100644 --- a/src/docs/prompts.md +++ b/src/docs/prompts.md @@ -124,7 +124,7 @@ Alternatively, you may leverage the power of Hypervel's [validator](/docs/{{vers ```php $name = text( label: 'What is your name?', - validate: ['name' => 'required|max:255|unique:users'] + validate: ['name' => ['required', 'max:255', 'unique:users']] ); ``` @@ -193,7 +193,7 @@ Alternatively, you may leverage the power of Hypervel's [validator](/docs/{{vers ```php $story = textarea( label: 'Tell me a story.', - validate: ['story' => 'required|max:10000'] + validate: ['story' => ['required', 'max:10000']] ); ``` @@ -278,7 +278,7 @@ Alternatively, you may leverage the power of Hypervel's [validator](/docs/{{vers ```php $copies = number( label: 'How many copies would you like?', - validate: ['copies' => 'required|integer|min:1|max:100'] + validate: ['copies' => ['required', 'integer', 'min:1', 'max:100']] ); ``` @@ -722,7 +722,7 @@ Alternatively, you may leverage the power of Hypervel's [validator](/docs/{{vers $name = suggest( label: 'What is your name?', options: ['Taylor', 'Dayle'], - validate: ['name' => 'required|min:3|max:255'] + validate: ['name' => ['required', 'min:3', 'max:255']] ); ``` diff --git a/src/docs/queues.md b/src/docs/queues.md index 2f19bce0e7..fb651e3928 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -4146,7 +4146,7 @@ class AppServiceProvider extends ServiceProvider The `duration` property of the `JobProcessed` event contains the number of milliseconds the job took to run. It is `null` when the worker skips the job because it was already deleted, such as by a `JobProcessing` listener. -If you would like to be notified when a job takes too long to run, you may use the `whenJobRunsLongerThan` method. Provide a threshold in milliseconds, or a `CarbonInterval`, and a closure that receives the job and the number of milliseconds the attempt took. The closure is invoked for each attempt that exceeds the threshold, whether or not the attempt succeeds: +If you would like to be notified when a job takes too long to run, you may use the `whenJobRunsLongerThan` method. Provide a threshold in milliseconds, or a `CarbonInterval`, and a closure that receives the job and the number of milliseconds the attempt took. The closure is invoked for each attempt that exceeds the threshold, whether or not the attempt succeeds, except an attempt stopped by coroutine cancellation: ```php use Hypervel\Contracts\Queue\Job; @@ -4157,7 +4157,13 @@ Queue::whenJobRunsLongerThan(1000, function (Job $job, float $duration) { }); ``` -Hypervel dispatches a `JobQueueing` event immediately before a job is sent to its queue and a `JobQueued` event after the queue accepts it. If the enqueue attempt throws an exception, a `JobQueueingFailed` event is dispatched with the original exception instead. This event is not dispatched when coroutine cancellation interrupts the attempt, including when the exception was caused by cancellation. Jobs deferred until a database transaction commits do not dispatch these events unless the enqueue attempt actually begins. +You may listen for `JobAttempted` to inspect an attempt's `connectionName`, `job`, `exception` and `duration`. The event is dispatched in the job's coroutine. If coroutine cancellation stops the attempt, including a job killed by its timeout, `canceled` is `true` and `successful()` returns `false`; normal completion steps and deferred callbacks are skipped. The event cannot be dispatched if the coroutine remains blocked until the worker exits. + +Hypervel dispatches a `JobQueueing` event immediately before a job is sent to its queue and a `JobQueued` event after the queue accepts it. If the enqueue attempt throws an exception, a `JobQueueingFailed` event is dispatched with the original exception instead. Its `sendAttempted` property is `false` when the attempt failed before the job was handed to the queue backend. + +Cancellation while enqueueing a job or notifying its listeners dispatches `JobQueueingCanceled`. Inspect `sendAttempted` to see whether sending began and `queued` to see whether the queue accepted the job before cancellation. The event also exposes the `cancellation` and any earlier enqueue failure as `exception`. Cancellation still propagates to the caller. + +Jobs deferred until a database transaction commits dispatch these events only when enqueueing begins. The `JobPayloadFinalizing` event runs immediately before `JobQueueing` and may replace its encoded `payload`. It also provides the connection, queue, job, and normalized delay in seconds. Use this event for last-mile payload changes that must reach the queue backend. Listening to both events deliberately runs both listeners for each asynchronous job. diff --git a/src/docs/sanctum.md b/src/docs/sanctum.md index 123820dca3..a2c905c9fd 100644 --- a/src/docs/sanctum.md +++ b/src/docs/sanctum.md @@ -700,7 +700,7 @@ use Hypervel\Validation\ValidationException; Route::post('/sanctum/token', function (Request $request) { $request->validate([ - 'email' => 'required|email', + 'email' => ['required', 'email'], 'password' => 'required', 'device_name' => 'required', ]); diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index 0bb606cb54..069d5983e6 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -784,10 +784,13 @@ Hypervel dispatches a variety of [events](/docs/{{version}}/events) during the s | `Hypervel\Console\Events\ScheduledBackgroundTaskFinished` | | `Hypervel\Console\Events\ScheduledTaskSkipped` | | `Hypervel\Console\Events\ScheduledTaskFailed` | +| `Hypervel\Console\Events\ScheduledTaskCanceled` | -The `ScheduledTaskFailed` event's `runtime` property contains the elapsed execution time in seconds, or `null` when the runtime is unavailable. +The `ScheduledTaskFailed` event's `runtime` property contains the elapsed execution time in seconds, or `null` when the runtime is unavailable. A failure before the task runs, such as an exception thrown by a `ScheduledTaskStarting` listener, dispatches `ScheduledTaskFailed` without a runtime and still stops `schedule:run`. + +Hypervel dispatches `ScheduledTaskCanceled` when coroutine cancellation interrupts a task or its completion event's listeners after `ScheduledTaskStarting`. The event exposes the `task`, the `cancellation` and any earlier task failure as `exception`. Its `runtime` is available only if the task finished running. Cancellation still propagates after the event. If you would like to be notified when a scheduled task takes too long to run, you may use the `whenTaskRunsLongerThan` method in your application's `routes/console.php` file. Provide a threshold in seconds, or a `CarbonInterval`, and a closure that receives the task and the number of seconds it ran. The closure is invoked once for each run that exceeds the threshold, including runs that fail: diff --git a/src/docs/schema-parser.md b/src/docs/schema-parser.md index b9141e6300..9cf2562a3c 100644 --- a/src/docs/schema-parser.md +++ b/src/docs/schema-parser.md @@ -13,6 +13,7 @@ - [Completeness and Errors](#completeness-and-errors) - [Checking Completeness](#checking-completeness) - [Handling Parse Errors](#handling-parse-errors) +- [Changes to an Existing Schema](#changes-to-an-existing-schema) - [Supported Input](#supported-input) - [Credits](#credits) @@ -221,6 +222,35 @@ If the parser detects malformed input, it throws a `Hypervel\SchemaParser\ParseE A complete result means the parser understood the schema facts it represents. It does not guarantee that a database server would accept every statement in the input. + +## Changes to an Existing Schema + +A parsed schema describes the definitions in its SQL input. When parsing SQL that modifies an existing database, you may also inspect `impact` to find existing tables, views and PostgreSQL types that the script may change: + +```php +use Hypervel\SchemaParser\PostgresParser; + +$impact = (new PostgresParser)->parse('ALTER TABLE users RENAME TO members')->impact; + +foreach ($impact->relations as $relation) { + echo $relation->name; // 'users', then 'members' +} + +$impact->unknown; // false +``` + +The impact provides three properties: + +- `relations` lists altered, dropped or renamed tables and views, including both names of a rename. +- `types` lists altered or dropped PostgreSQL enums and domains. Type renames are represented by `unknown` instead. +- `unknown` is `true` when the affected definitions cannot all be named, such as with cascading drops, queries that call functions, or procedure calls. It is also set for changes to name resolution, such as `USE` or `SET search_path`, which may affect separately parsed SQL. + +Listed definitions may no longer exist or retain their original columns or types, even if the script also creates them. Unqualified names use the schema selected by the input, such as SQLite's `main` or a database selected by MySQL's `USE` statement. + +Index, comment, routine, trigger and sequence changes, row updates and other session settings do not invalidate these definitions. Row changes can still run triggers or functions on the server; their effects are not included. + +Tables and views the script creates are available through the schema's usual properties. Definitions guarded by `IF NOT EXISTS` are included too; the parser does not know whether they already exist in your database. + ## Supported Input diff --git a/src/docs/sentry.md b/src/docs/sentry.md index 05740741d8..0bd1abcff3 100644 --- a/src/docs/sentry.md +++ b/src/docs/sentry.md @@ -370,6 +370,8 @@ When no monitor slug is given, one is derived from the task's command or descrip Sentry expects a check-in for every run of the task's schedule. Tasks limited by `between`, `unlessBetween`, `when`, or `skip` do not check in when they are skipped, so their skipped runs are reported as missed. Prefer expressing the schedule with the `cron` method. +Failed runs check in as errors, including failures before completion callbacks run. When coroutine cancellation interrupts a run without a known failure, any unfinished check-in is left open because Sentry has no canceled status. Sentry later reports it as timed out. An earlier task failure, or a cleanup failure before the task's outcome is known, still produces an error check-in. + ### OpenTelemetry diff --git a/src/docs/static-analysis.md b/src/docs/static-analysis.md new file mode 100644 index 0000000000..5534500390 --- /dev/null +++ b/src/docs/static-analysis.md @@ -0,0 +1,1210 @@ +# Static Analysis + +- [Introduction](#introduction) +- [Installation](#installation) + - [Analyzing Packages](#analyzing-packages) + - [Runtime Requirements](#runtime-requirements) +- [Configuration](#configuration) +- [What the Analysis Knows](#what-the-analysis-knows) + - [Configuration and Services](#configuration-and-services) + - [Helpers and Factories](#helpers-and-factories) + - [Collection Types](#collection-types) + - [Model Properties](#model-properties) + - [Migrations and Schema Dumps](#migrations-and-schema-dumps) + - [Accessor Types](#accessor-types) + - [Cast Types](#cast-types) + - [Model Serialization](#model-serialization) + - [Relationships](#relationships) + - [Custom Builders and Collections](#custom-builders-and-collections) +- [Custom PHPDoc Types](#custom-phpdoc-types) + - [The `view-string` Type](#view-string) + - [The `model-property` Type](#model-property) + - [The `collection-of` Type](#collection-of) + - [The `builder-of` Type](#builder-of) + - [The `relation-of` Type](#relation-of) +- [Fixing and Ignoring Diagnostics](#fixing-and-ignoring-diagnostics) + - [Environment Variables](#environment-variables) + - [Configuration Values](#configuration-values) + - [Array Keys](#array-keys) + - [Value and With Helpers](#value-and-with-helpers) + - [Converting Collections to Arrays](#converting-collections-to-arrays) + - [Translations](#translations) + - [Views](#views) + - [Console Input](#console-input) + - [Authentication in Requests](#authentication-in-requests) + - [Redis Connections](#redis-connections) + - [Cancellation](#cancellation) + - [Coroutines in Transactions](#coroutines-in-transactions) + - [Sleeping](#sleeping) + - [Worker State](#worker-state) + - [Runtime Configuration](#runtime-configuration) + - [Model Relations](#model-relations) + - [Model Columns](#model-columns) + - [Model Scopes and Accessors](#model-scopes-and-accessors) + - [Model Configuration](#model-configuration) + - [Appended Attributes](#appended-attributes) + - [Creating Models](#creating-models) + - [Query Results](#query-results) + - [Starting Queries](#starting-queries) + - [Schema Dumps](#schema-dumps) + - [Dispatching Jobs and Events](#dispatching-jobs-and-events) + - [Dispatching Jobs in Transactions](#dispatching-jobs-in-transactions) + - [Models in Queued Jobs](#models-in-queued-jobs) + - [Batched Jobs](#batched-jobs) + - [Unique Jobs](#unique-jobs) + - [Queue Configuration](#queue-configuration) + - [Validation Rules](#validation-rules) + - [Deprecations](#deprecations) + + +## Introduction + +Hypervel's `hypervel/phpstan` package extends [PHPStan](https://phpstan.org) with support for Eloquent models, container services, configuration and other framework features. It combines [Larastan](https://github.com/larastan/larastan)'s framework analysis with additional checks for common Hypervel mistakes, including coroutine and long-running worker issues. + + +## Installation + +New applications include the package already, so you may run `composer analyse` right away. To add it to an existing application or package, install it as a development dependency. The package requires PHPStan 2.3.5 or later: + +```shell +composer require hypervel/phpstan --dev +``` + +Next, include the extension and the bundled deprecation rules in your `phpstan.neon` file: + +```neon +includes: + - vendor/hypervel/phpstan/extension.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + +parameters: + level: 5 + paths: + - app +``` + +If your project uses the [PHPStan extension installer](https://github.com/phpstan/extension-installer), it loads both extensions automatically, so you should omit the `includes` entries. Once configured, you may analyze your application: + +```shell +./vendor/bin/phpstan analyse +``` + +You should load the extension whenever you analyze Hypervel code. Without it, PHPStan cannot resolve some framework PHPDoc types, such as eager loading arrays, and falls back to the native declarations. + + +### Analyzing Packages + +To analyze a package, install `hypervel/testbench` as a development dependency. PHPStan uses the providers, configuration and package discovery settings in your `testbench.yaml` file, just like your package's tests. Authentication types come from the Testbench application, where `AUTH_MODEL` or the workbench `User` model selects the user model. + + +### Runtime Requirements + +Since PHPStan boots your application, the PHP CLI that runs it needs the same Swoole setup as your application: Swoole (not OpenSwoole) built with OpenSSL and native cURL support, with `swoole.use_shortname` set to `Off`. If the application cannot boot, PHPStan stops and displays the exception that prevented it. + + +## Configuration + +The extension's checks are configured using parameters in your `phpstan.neon` file. For example, you may enable a check that is off by default, or disable one your project does not want: + +```neon +parameters: + checkUnusedViews: true + noLegacyModelScopes: false +``` + +Parameters that accept directories take absolute paths, or paths relative to the directory you run PHPStan from. The following table lists each parameter, its default value and the section that describes it: + +| Parameter | Default | Described In | +|---|---|---| +| `noEnvCallsOutsideOfConfig` | `true` | [Environment Variables](#environment-variables) | +| `checkConfigTypes` | `true` | [Configuration Values](#configuration-values) | +| `configDirectories` | `[]` | [Configuration Values](#configuration-values) | +| `noUnnecessaryEnumerableToArrayCalls` | `true` | [Converting Collections to Arrays](#converting-collections-to-arrays) | +| `checkMissingTranslations` | `true` | [Translations](#translations) | +| `translationDirectories` | `[]` | [Translations](#translations) | +| `checkUnusedViews` | `false` | [Views](#views) | +| `viewDirectories` | `[]` | [Views](#views) | +| `checkAuthCallsWhenInRequestScope` | `false` | [Authentication in Requests](#authentication-in-requests) | +| `checkRedisConnectionBoundMethods` | `true` | [Redis Connections](#redis-connections) | +| `checkCancellationHandling` | `true` | [Cancellation](#cancellation) | +| `checkCancellationPrecedence` | `true` | [Cancellation](#cancellation) | +| `noCoroutinesInTransactions` | `true` | [Coroutines in Transactions](#coroutines-in-transactions) | +| `noRawSleepCalls` | `true` | [Sleeping](#sleeping) | +| `checkWorkerSharedState` | `true` | [Worker State](#worker-state) | +| `checkStaticStateMutation` | `true` | [Worker State](#worker-state) | +| `checkUnboundedWorkerCaches` | `true` | [Worker State](#worker-state) | +| `noRuntimeConfigMutation` | `true` | [Runtime Configuration](#runtime-configuration) | +| `databaseMigrationsPath` | `[]` | [Migrations and Schema Dumps](#migrations-and-schema-dumps) | +| `squashedMigrationsPath` | `[]` | [Migrations and Schema Dumps](#migrations-and-schema-dumps) | +| `disableMigrationScan` | `false` | [Migrations and Schema Dumps](#migrations-and-schema-dumps) | +| `disableSchemaScan` | `false` | [Migrations and Schema Dumps](#migrations-and-schema-dumps) | +| `enableMigrationCache` | `true` | [Migrations and Schema Dumps](#migrations-and-schema-dumps) | +| `parseModelCastsMethod` | `true` | [Cast Types](#cast-types) | +| `checkModelProperties` | `true` | [Model Columns](#model-columns) | +| `eloquentBuilderMacros` | The `SoftDeletes` and Scout `Searchable` macros | [Custom Builders and Collections](#custom-builders-and-collections) | +| `noLegacyModelScopes` | `true` | [Model Scopes and Accessors](#model-scopes-and-accessors) | +| `checkModelMethodVisibility` | `true` | [Model Scopes and Accessors](#model-scopes-and-accessors) | +| `noLegacyModelProperties` | `true` | [Model Configuration](#model-configuration) | +| `checkModelAppends` | `true` | [Appended Attributes](#appended-attributes) | +| `noModelMake` | `true` | [Creating Models](#creating-models) | +| `noUnnecessaryCollectionCall` | `true` | [Query Results](#query-results) | +| `noUnnecessaryCollectionCallOnly` | `[]` | [Query Results](#query-results) | +| `noUnnecessaryCollectionCallExcept` | `[]` | [Query Results](#query-results) | +| `noImplicitQueryBuilderCall` | `false` | [Starting Queries](#starting-queries) | +| `checkDispatchInTransactionAfterCommit` | `true` | [Dispatching Jobs in Transactions](#dispatching-jobs-in-transactions) | +| `checkJobSerializesModels` | `true` | [Models in Queued Jobs](#models-in-queued-jobs) | +| `checkBatchedJobIsBatchable` | `true` | [Batched Jobs](#batched-jobs) | +| `noBatchedUniqueJob` | `true` | [Batched Jobs](#batched-jobs) | +| `checkBatchableJobChecksCancellation` | `false` | [Batched Jobs](#batched-jobs) | +| `checkUniqueJobUniqueId` | `true` | [Unique Jobs](#unique-jobs) | +| `checkUniqueJobUniqueFor` | `false` | [Unique Jobs](#unique-jobs) | +| `noLegacyQueueProperties` | `true` | [Queue Configuration](#queue-configuration) | +| `noPipeSeparatedValidationRules` | `true` | [Validation Rules](#validation-rules) | + +PHPStan's own settings, such as its rule level, paths and baseline, keep working as usual. The extension changes one of them: it enables `reportIgnoresWithoutComments`, as described in [Fixing and Ignoring Diagnostics](#fixing-and-ignoring-diagnostics). + + +## What the Analysis Knows + + +### Configuration and Services + +Configuration types come from your configuration files themselves, never from the values on your machine. For example, the following `enabled` option is a `bool` no matter what your `.env` file contains: + +```php +// config/feature.php +return ['enabled' => (bool) env('FEATURE_ENABLED', false)]; + +// Inferred as bool... +$enabled = config('feature.enabled'); +``` + +Configuration types assume you are reading your application's configuration repository. If you build a separate repository, such as `new Repository($items)`, add a PHPDoc type to the values you read from it, or ignore a reported call with a reason when needed. + +PHPStan boots your application to discover its services, authentication guards, commands, views and translations. Run analysis with the same environment locally and in CI, since settings such as `DB_CONNECTION` and `AUTH_MODEL` affect the inferred types. Package analysis uses your Testbench application's registrations. + +A service resolved using `app(Repository::class)` has the type of its container binding. Helpers such as `response()` and `url()` also return their bound implementations, so those classes' methods and registered macros are available. A value typed as an interface exposes only that interface's methods, even when the bound class has additional methods or macros. Use the concrete type when your code requires them. + +Declare parameter and return types on macro closures and register them during application boot so PHPStan can check their calls. A class's `@method` tag takes precedence over a macro's signature. Macros registered later, such as in a controller action, are not discovered. + +Calls forwarded to a manager's configured default driver use the return type of its `create...Driver()` method or `extend()` callback, unless the manager declares the method or a `@mixin` for its drivers. Declare these return types so PHPStan can check the calls without creating the driver. + +Filesystem calls use your configured disk types. With the built-in drivers, S3 disks return a `ClientPooledFilesystem` and local disks return a `LocalFilesystemAdapter`. Calls through the `Storage` facade use the default disk's methods. An unknown disk name keeps the `Filesystem` contract type. + +Concurrency drivers use your `concurrency` configuration: `Concurrency::driver('sync')` returns a `SyncDriver`. Unknown names and custom drivers registered with `extend()` keep the `Driver` contract type. + +PHPStan reuses cached results for unchanged code. Changes to the environment, service providers or other boot files also invalidate results that depend on the application's registrations. + + +### Helpers and Factories + +Use typed request helpers such as `$request->string()`, `integer()` and `boolean()` when you need a specific type. Values read by key using `query()`, `post()` or `cookie()` may be strings, numbers, booleans, arrays or `Stringable` objects. Input arrays may also have integer keys, such as those from a JSON list body. + +The `now()` and `today()` helpers and the `Date` facade return `CarbonInterface`, even when you configure a specific date class. Use a conversion method such as `toImmutable()` when you need a more specific type. + +Model factories retain their model types: `User::factory()->create()` returns a `User`, while `User::factory(3)->create()` returns a collection. The `createMany`, `createManyQuietly` and `makeMany` methods reset the factory's count. If you reuse that factory, assign these methods' results before passing them to another call so PHPStan can track the reset. Factories with their own `@template` declarations keep their generic types and may return either a model or a collection. + +The `Arr::get` and `Arr::pull` methods return the type the array holds under the given key, including keys in "dot" notation. When the key may be missing, the type of the default is added, or `null` without one. `Arr::pull` also removes a top-level key from the type of the array it was given: + +```php +/** + * @param array{name: string, email?: string, address: array{city: string}} $user + */ +function example(array $user): void +{ + Arr::get($user, 'name'); // string + Arr::get($user, 'email'); // string|null + Arr::get($user, 'address.city'); // string + + Arr::pull($user, 'name'); // string, and $user no longer has a "name" key... +} +``` + +When a value cannot be inferred, such as configuration written by code the analysis cannot follow, PHPStan keeps the method's declared return type. + +Calling `str()` without arguments returns an object that passes method calls to `Str`. The analysis does not check those calls. + + +### Collection Types + +Collections keep Laravel's declared types, so a collection of users may be passed where a collection of models is expected. Writes through the wider reference are checked against its model type, so the analysis can miss a value that violates the original collection's narrower user type. + +If you enable PHPStan's [bleeding edge](https://phpstan.org/blog/what-is-bleeding-edge) mode, PHPStan keeps the value type it infers for a new collection, so adding or searching for a value of another type is reported: + +```php +$ids = collect([1, 2, 3]); + +$ids->push(4); // Reported with bleeding edge... +``` + +Declare the type the collection is meant to hold when you create it: + +```php +/** @var Collection $ids */ +$ids = collect([1, 2, 3]); +``` + +A custom collection class that fixes its item type, such as a `UserCollection` that extends `Collection`, keeps that type when methods such as `map()`, `mapWithKeys()`, `groupBy()` and `chunk()` return the same class holding other items. This is a PHPStan limitation. For example, `$users->groupBy('team_id')` is typed as a `UserCollection` of users, although it holds user collections. Declare the result's type where your code relies on it: + +```php +use Hypervel\Support\Collection; + +/** @var Collection $teams */ +$teams = $users->groupBy('team_id'); +``` + + +### Model Properties + +Model attributes are typed from the database schema your migrations and schema dumps describe, without connecting to a database. Reading an attribute the model's table does not have is reported: + +```php +public function greeting(User $user): string +{ + return "Hello, {$user->nmae}"; // Access to an undefined property App\Models\User::$nmae... +} +``` + +Each model reads the schema of its own connection. A pooled connection, such as `pgsql-pooled`, reads the schema of the connection its migrations run on. + +Attributes are typed as your database driver returns them. For example, a `decimal` column reads as a numeric string on MySQL, since decimals are fetched as strings to keep their precision. The connection's PDO options, such as `PDO::ATTR_STRINGIFY_FETCHES`, are taken into account. On SQLite, the types describe the values Hypervel writes, since SQLite only enforces column types on `STRICT` tables. [Casts](/docs/{{version}}/eloquent-mutators#attribute-casting), dates and accessors take precedence over the column's type, so cast an attribute when your code needs a specific type. + +When the analysis cannot know whether a table has a column, such as after a migration it cannot follow, the attribute reads as `mixed` and is not reported as missing. + + +### Migrations and Schema Dumps + +By default, PHPStan reads migrations registered with `loadMigrationsFrom` and those in `database/migrations`, using the same file selection and name order as `migrate`. Published migrations replace package migrations of the same name. [Schema dumps](/docs/{{version}}/migrations#squashing-migrations) are read from `database/schema`. + +You may override these directories using `databaseMigrationsPath` and `squashedMigrationsPath`. Both accept glob patterns and replace their default paths. Unlike default migration discovery, custom migration directories are searched recursively for all `.php` files: + +```neon +parameters: + databaseMigrationsPath: + - database/migrations + - modules/*/migrations + squashedMigrationsPath: + - database/schema +``` + +A schema dump is only read when it has the name the `schema:dump` Artisan command gives it: `{connection}-schema.sql`, or `{connection}-schema.dump`, where `{connection}` is a connection in your `database` configuration. When both exist, the `.dump` file is read, as the `migrate` command does. The schema of a connection with no dump and no migrations is unknown. + +PHPStan reads migrations without executing them. It follows schema builder calls, `if` and `switch` statements, `Schema::hasTable()` and `Schema::hasColumn()` checks, and literal SQL passed to `DB::statement()` or `DB::unprepared()`. It also follows the methods a migration calls on itself, including an `up` method or helpers it inherits from a base migration class or a trait. Each call uses the migration's connection unless it selects another with `Schema::connection()` or `DB::connection()`, including through a builder stored in a variable: + +```php +$schema = Schema::connection('analytics'); + +$schema->create('events', function (Blueprint $table) { + // ... +}); +``` + +Dynamic migration code can limit attribute typing. Blueprint macros, loops and helpers that receive a blueprint make its table unknown. A `switch` statement whose case can end early, such as through a `break` inside an `if`, makes the tables it changes unknown. When a migration, its base class or one of its traits assigns the `$connection` property outside the `down` method, such as in a constructor, the connection its calls use by default is unknown. A trait method a migration renames with `as` or selects with `insteadof`, or a method called by a dynamic name such as `$this->{$method}()`, makes every connection's schema unknown. A dynamic method name on the `Schema` facade or a schema builder, such as `Schema::{$method}()`, makes that connection's schema unknown. Unknown column names leave room for additional columns; unknown table names make the connection's schema unknown, and unknown connection names make every connection's schema unknown. A column created in only some branches reads as `mixed`. + +Use the `Schema` and `DB` facades for schema changes. Unlike a schema builder, a database connection stored in a variable is not followed, so `$connection = DB::connection('analytics')` makes that connection's schema unknown, even if the connection only updates rows. Schema changes through connections obtained in other ways, such as from a property or `app('db')`, are not followed either. To target another database or schema, configure a connection and select it instead of relying on `USE` or `SET search_path` across separate SQL calls. + +Migrations and schema dumps are cached in PHPStan's temporary directory and read again when they, or the classes and configuration they read, change. You may disable the cache using the `enableMigrationCache` parameter, or stop reading migrations or schema dumps using the `disableMigrationScan` or `disableSchemaScan` parameter. + + +### Accessor Types + +Declare an accessor's read and write types using `Attribute`. Use `never` for the write type when the accessor only has a getter: + +```php +/** + * @return Attribute + */ +protected function fullName(): Attribute +{ + return Attribute::make( + get: fn () => "{$this->first_name} {$this->last_name}", + ); +} +``` + +Set the `get` and `set` callbacks when you create the `Attribute`. PHPStan treats them as read-only afterwards; this does not restrict reading or writing the model attribute itself. If you need to replace a callback, ignore `property.readOnlyByPhpDocAssignOutOfClass` with a reason. + + +### Cast Types + +PHPStan reads the array returned by your model's `casts` method. Casts may use strings, class constants, `[$cast, ...$arguments]`, or helpers such as `AsCollection::of` and `AsEnumCollection::of` with constant arguments. + +An attribute with an unknown cast reads and writes as `mixed`. If the method builds its array dynamically or has multiple return statements, its casts cannot be inferred and attributes read as `mixed`. Declare an array shape when composing casts this way: + +```php +/** + * @return array{options: 'array', status: 'App\\Enums\\Status'} + */ +protected function casts(): array +{ + return array_merge(parent::casts(), [ + 'options' => 'array', + 'status' => Status::class, + ]); +} +``` + +Include every returned cast, including inherited casts. An attribute omitted from a closed shape is treated as not cast by that method. If you cannot list them all, end the shape with `...` so unlisted attributes remain `mixed`. + +If you set the `parseModelCastsMethod` parameter to `false`, the method's body is not read, and its casts come only from the array shape its `@return` tag declares. + + +### Model Serialization + +A model's `toArray` and `attributesToArray` methods return an array shape built from the model's columns, casts, accessors and appended attributes, without its hidden attributes. For example, a `posts` table with an integer `id`, a string `title` and a nullable `published_at` column cast to `datetime` gives: + +```php +// array{id?: int, title?: string, published_at?: string|null, ...} +$post->toArray(); +``` + +Every key is optional, since a query may select only some columns, and the shape accepts other string keys for the aliases, aggregates and loaded relations a query can add. A model that customizes how it is serialized, or whose columns are not all known, keeps the declared `array` type. + + +### Relationships + +The analysis reads a relationship's related model from the relationship method's return type, so declare the relation's generic types: + +```php +/** + * @return HasMany + */ +public function posts(): HasMany +{ + return $this->hasMany(Post::class); +} +``` + +With this declaration, `$user->posts` is a collection of posts, `$user->posts_count` is an integer, and callbacks that constrain the relationship receive the right types without declaring them: + +```php +User::whereHas('posts.comments', function ($query) { + // $query is a Builder... +}); + +User::with([ + 'posts' => function ($query) { + // $query is a HasMany... + }, + 'group' => function ($query) { + // $query is a BelongsTo... + }, +]); +``` + +Each callback in an eager loading array receives its own key's relation, following nested paths such as `posts.comments`. Callbacks given to the `loadCount`, `loadSum` and other aggregate methods receive the related model's builder. A relationship whose name is not statically known, such as an arbitrary string, or a method without the generic types above, gives the callback an unknown relation or a builder of an unknown model. + +A relationship method without a relation return type, such as an untyped method, is still recognized, but its related model is unknown: `$user->posts` is `mixed`, and a nested path such as `posts.comments` is not checked beyond it. + +A relationship registered using `resolveRelationUsing` while the application boots is typed by its resolver's return type, which the analysis reads without running the resolver. Calls such as `throughCars()` and `hasOwner()` have the types of `through('cars')` and `has('owner')`. + + +### Custom Builders and Collections + +A model's queries are typed as its [custom Eloquent builder](/docs/{{version}}/eloquent#custom-eloquent-builders), so the builder's own methods may be chained from the model, its relationships and relationship callbacks. Declare the model as the builder's template type so that results keep the model's type: + +```php +/** + * @template TModel of User + * + * @extends Builder + */ +class UserBuilder extends Builder +{ + /** + * @return $this + */ + public function active(): static + { + return $this->where('active', true); + } +} + +#[UseEloquentBuilder(UserBuilder::class)] +class User extends Model +{ + // ... +} + +// UserBuilder, then Collection... +$users = User::query()->active()->get(); +``` + +If a model trait overrides `newEloquentBuilder`, as the [nested set](/docs/{{version}}/nested-set#model-setup) `HasNode` trait does, you may declare your builder type using `HasBuilder`. This declaration takes precedence during analysis; keep the runtime builder configuration shown above: + +```php +/** @use HasBuilder> */ +use HasBuilder; +``` + +If your Eloquent builder uses a custom query builder, declare that type on the builder's `getQuery` method, so methods forwarded to the query builder are typed from it. + +Likewise, query results are typed as the model's [custom collection](/docs/{{version}}/eloquent-collections#custom-collections), whether it comes from the `CollectedBy` attribute or a `newCollection` method. The same collection is returned by a paginator's `getCollection` method. Give a collection used by several models `TKey` and `TModel` template types, so each model's results keep their own type: + +```php +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class ActiveCollection extends Collection +{ + // ... +} +``` + +To describe builder macros registered by a trait's global scope, map the trait to an interface using `eloquentBuilderMacros`. Declare the macro signatures on the interface and continue registering them in the scope at runtime. A `static` return type preserves the calling builder's type; an interface with one template type receives the model: + +```neon +parameters: + eloquentBuilderMacros: + App\Models\Concerns\Archivable: App\Analysis\ArchivableMacros +``` + +Your entries are added to the built-in mappings for `SoftDeletes` and Scout's `Searchable` trait. + + +## Custom PHPDoc Types + +The extension adds several PHPDoc types that you may use in your own code. Since only PHPStan with this extension understands them, write them in `@phpstan-param` and `@phpstan-return` tags beside an ordinary `@param` or `@return` tag when other tools read your docblocks. + + +### The `view-string` Type + +A `view-string` is the name of a view the booted application can find. A statically known name passed to a `view-string` parameter is checked, as described in [Views](#views): + +```php +/** + * @phpstan-param view-string $view + */ +public function render(string $view): View +{ + return view($view); +} +``` + +From PHPStan's level 7, a string that is not known to be a view name is reported as well. A string checked using `view()->exists()` becomes a `view-string` inside the condition, so a name that is not known in advance may be passed on: + +```php +if (view()->exists($name)) { + return $this->render($name); +} +``` + + +### The `model-property` Type + +A `model-property` is a string naming one of the model's attributes. A statically known name passed to such a parameter is checked against the model, as described in [Model Columns](#model-columns): + +```php +/** + * @phpstan-param model-property $column + */ +function sortUsersBy(string $column): Collection +{ + return User::orderBy($column)->get(); +} + +sortUsersBy('emial'); // Reported... +``` + + +### The `collection-of` Type + +A `collection-of` is the collection class the model's queries return: the model's custom collection when it has one, otherwise `Hypervel\Database\Eloquent\Collection`. The model may also be a template type or a union of models: + +```php +/** + * @template TModel of Model + * + * @param class-string $model + * + * @phpstan-return collection-of + */ +function allModels(string $model): Collection +{ + return $model::all(); +} +``` + +Like `array`, a key type may be given first, such as `collection-of`. + + +### The `builder-of` Type + +A `builder-of` is the Eloquent builder the model's `newQuery()` creates, which `query()`, forwarded calls and relationships use: the model's custom builder when it has one, otherwise `Hypervel\Database\Eloquent\Builder`. It accepts `static`, `$this`, template types and unions of models: + +```php +/** + * @phpstan-return builder-of + */ +public function newActiveQuery(): Builder +{ + return $this->newQuery()->where('active', true); +} +``` + +A second argument names a relationship of the model, and gives the builder of the related model. Nested paths are followed, and a column list such as `posts:id,title` is ignored: + +```php +/** + * @phpstan-param builder-of $query + */ +function onlyApproved(Builder $query): void +{ + $query->where('approved', true); +} +``` + +A relationship the analysis cannot resolve, such as a name that is not statically known or a relationship method without generic types, gives a builder of an unknown model. + + +### The `relation-of` Type + +A `relation-of` is the relation the model's relationship method declares, such as `HasMany`. Like `builder-of`, it accepts template types, nested paths and column lists. When the name may be one of several relationships, the type is a union of their relations, so code receiving it must work with each of them. A relationship the analysis cannot resolve gives `Relation<*, *, *>`. + + +## Fixing and Ignoring Diagnostics + +Whenever possible, fix the code a diagnostic points to. Ignore a diagnostic only for a legitimate exception or a limit of the analysis, and give the ignore the diagnostic's identifier and a reason: + +```php +// @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the test runner sets this token for each worker) +$token = Env::get('TEST_TOKEN'); +``` + +The extension enables `reportIgnoresWithoutComments`, so each ignored identifier needs a reason. Blanket `@phpstan-ignore-line` and `@phpstan-ignore-next-line` comments are also reported. To ignore several diagnostics on one line, give each its own reason: + +```php +// @phpstan-ignore property.notFound (Queueable property), method.notFound (Queueable method) +``` + +Fix confirmed bugs rather than adding them to a baseline. + +You may run `phpstan analyse --fix` to convert pipe-separated validation rules to arrays, remove unnecessary `value` and `with` calls, make private scopes and accessors protected, and start model queries with `query()` when that preference is enabled. Review the changes before committing them. + +The following sections explain the diagnostics and their remedies. Use this table to find a diagnostic by its identifier: + +| Topic | Identifiers | +|---|---| +| [Environment Variables](#environment-variables) | `hypervel.noEnvCallsOutsideOfConfig` | +| [Configuration Values](#configuration-values) | `hypervel.configType`, `hypervel.configCollection` | +| [Array Keys](#array-keys) | `offsetAccess.notFound` | +| [Value and With Helpers](#value-and-with-helpers) | `hypervel.uselessConstructs.value`, `hypervel.uselessConstructs.with` | +| [Converting Collections to Arrays](#converting-collections-to-arrays) | `hypervel.unnecessaryEnumerableToArrayCall` | +| [Translations](#translations) | `hypervel.missingTranslations`, `hypervel.invalidTranslationLocale` | +| [Views](#views) | `hypervel.unusedViews`, `argument.type` for view names | +| [Console Input](#console-input) | `hypervel.console.undefinedArgument`, `hypervel.console.undefinedOption` | +| [Authentication in Requests](#authentication-in-requests) | `hypervel.noAuthFacadeInRequestScope`, `hypervel.noAuthHelperInRequestScope` | +| [Redis Connections](#redis-connections) | `hypervel.redisConnectionBoundMethod` | +| [Cancellation](#cancellation) | `hypervel.cancellation.exactType`, `hypervel.cancellation.precedence` | +| [Coroutines in Transactions](#coroutines-in-transactions) | `hypervel.coroutineInTransaction` | +| [Sleeping](#sleeping) | `hypervel.rawSleep` | +| [Worker State](#worker-state) | `hypervel.workerSharedState`, `hypervel.staticStateMutation`, `hypervel.unboundedWorkerCache` | +| [Runtime Configuration](#runtime-configuration) | `hypervel.runtimeConfigMutation` | +| [Model Relations](#model-relations) | `hypervel.relationExistence` | +| [Model Columns](#model-columns) | `argument.type` for column names, `method.notFound` and `staticMethod.notFound` for dynamic wheres | +| [Model Scopes and Accessors](#model-scopes-and-accessors) | `hypervel.legacyModelScope`, `hypervel.noPublicModelScopeMethod`, `hypervel.noPublicModelAccessorMethod` | +| [Model Configuration](#model-configuration) | `hypervel.legacyModelProperty` | +| [Appended Attributes](#appended-attributes) | `hypervel.modelAppends` | +| [Creating Models](#creating-models) | `hypervel.noModelMake` | +| [Query Results](#query-results) | `hypervel.noUnnecessaryCollectionCall` | +| [Starting Queries](#starting-queries) | `hypervel.noImplicitQueryBuilderCall` | +| [Schema Dumps](#schema-dumps) | `hypervel.schemaDump` | +| [Dispatching Jobs and Events](#dispatching-jobs-and-events) | `hypervel.jobs.noConstructor`, `hypervel.events.noConstructor`, `argument.type` and the other argument identifiers | +| [Dispatching Jobs in Transactions](#dispatching-jobs-in-transactions) | `hypervel.dispatchInTransactionAfterCommit` | +| [Models in Queued Jobs](#models-in-queued-jobs) | `hypervel.jobSerializesModels` | +| [Batched Jobs](#batched-jobs) | `hypervel.batchedJobIsBatchable`, `hypervel.noBatchedUniqueJob`, `hypervel.batchableJobChecksCancellation` | +| [Unique Jobs](#unique-jobs) | `hypervel.uniqueJobUniqueId`, `hypervel.uniqueJobUniqueFor` | +| [Queue Configuration](#queue-configuration) | `hypervel.legacyQueueProperty` | +| [Validation Rules](#validation-rules) | `hypervel.pipeSeparatedValidationRules` | +| [Deprecations](#deprecations) | `method.deprecated` and the other deprecation identifiers | + + +### Environment Variables + +You should only read environment variables in your configuration files. Once your configuration is cached, the `.env` file is no longer loaded, so a read anywhere else only sees the process environment. Instead, read the variable in a configuration file and use `config()` elsewhere: + +```php +// config/services.php +'billing' => [ + 'key' => env('BILLING_KEY'), +], + +// Elsewhere in your application... +$key = config('services.billing.key'); +``` + +The check covers direct calls to `env`, `env_array`, `env_or_fail`, `app_id`, `Env::get`, `Env::getArray` and `Env::getOrFail`. Configuration files, including those merged or published by packages, are allowed. Dynamic calls, `getenv()` and `$_ENV` are not checked. + +Some infrastructure code genuinely needs the process environment, such as code that runs before configuration loads, values passed to child processes, or settings provided by a test runner. Ignore those calls individually, with a reason. You may disable the rule using the `noEnvCallsOutsideOfConfig` parameter. + + +### Configuration Values + +The typed configuration getters, such as `Config::string()` and `config()->integer()`, throw an exception when the value has a different type. The analysis reports a typed getter, or a `collection()` call, whose key can never hold the required type: + +```php +// config/queue.php +'retries' => '3', + +// Config key 'queue.retries' is not an integer, so integer() throws... +$retries = Config::integer('queue.retries'); +``` + +Correct the configuration value, casting environment variables within the configuration file, or use the getter that matches the value's type. Reserve `get()` for values that are intentionally nullable, a union of types, or unrestricted: + +```php +'retries' => (int) env('QUEUE_RETRIES', 3), +``` + +Only values the analysis knows are checked. A key the analysis cannot follow keeps the getter's declared type. + +Configuration types include the defaults of any package whose configuration your application has not published. When you analyze a package, values merged from package configuration files keep the getter's declared type instead, since the applications using the package replace them. The framework's defaults and your workbench configuration files are still typed. These checks may be disabled using the `checkConfigTypes` parameter. + +Your application's configuration files are read from its configuration directory. If they live elsewhere, list the directories using the `configDirectories` parameter, which replaces the default directory and accepts glob patterns: + +```neon +parameters: + configDirectories: + - config + - modules/*/config +``` + + +### Array Keys + +Calls to `Arr::get()` and `Arr::pull()` are checked against the keys the array is known to have, following keys in "dot" notation through nested arrays. A key the array never has is reported, since the call always returns `null`: + +```php +/** + * @param array{name: string, email: string} $user + */ +function notify(array $user): void +{ + $email = Arr::get($user, 'mail'); // Offset 'mail' does not exist... +} +``` + +Correct the key. When a key is optional by design, pass the default you expect, since calls with a default are not reported: + +```php +$timeout = Arr::get($options, 'timeout', 30); +``` + +Arrays whose keys are not known, such as `array`, are not checked. + + +### Value and With Helpers + +The `value` helper only calls a closure, and the `with` helper only calls its callback, so a call that passes neither returns the value unchanged and is reported: + +```php +$name = value($user->name); // Reported... +$name = with($user->name); // Reported... + +$name = $user->name; +``` + +Use the value directly. When the value may be a closure, such as a default that callers may compute lazily, keep the `value` call; the analysis does not report it. + + +### Converting Collections to Arrays + +A collection's `toArray()` method converts each `Arrayable` item to an array as well, while `all()` returns the items as they are. When no item is `Arrayable`, the two return the same array, so the analysis recommends `all()`: + +```php +// Called [toArray()] on an Enumerable which does not contain any Arrayables... +$ids = collect([1, 2, 3])->toArray(); + +$ids = collect([1, 2, 3])->all(); +``` + +Keep `toArray()` when the items are, or may be, `Arrayable`, such as models. Collections that override `toArray()`, `all()` or `map()` are not checked. You may disable the rule using the `noUnnecessaryEnumerableToArrayCalls` parameter. + + +### Translations + +PHPStan checks literal `group.item` and `namespace::group.item` translation keys used by the translation helpers, `Lang` facade and translator. It uses the requested locale and fallback, or your application's default locale. Checks require the group's translation file to exist; JSON translations, dynamic keys and custom translators or loaders are not checked. A locale the translator rejects, such as one containing `/` or `..`, is reported too. + +To fix the diagnostic, add the missing line or correct the key or locale. Keys are checked against the translations your application loads. To check them against other directories instead, list the directories using the `translationDirectories` parameter. You may disable the rule using the `checkMissingTranslations` parameter. + + +### Views + +Methods that accept a view name, such as `view()` and `View::make()`, require a view the booted application can find, so a misspelled view name is reported as an `argument.type` error. If a package only registers a view namespace when a command runs or a feature is enabled, the analysis cannot see those views; ignore those calls with a reason. + +You may also enable the `checkUnusedViews` parameter to report views that no code refers to by a known name, such as a string or a constant. Views rendered by computed names or used by other packages may still be in use, so treat these diagnostics as a list to review rather than proof that a view can be deleted. + +View usage is recognized in view helpers, view routes, mail definitions, Blade directives that render views, such as `@extends`, `@include` and `@each`, and anonymous component tags such as ``, including components in directories registered with `Blade::anonymousComponentPath()` or `Blade::anonymousComponentNamespace()`. By default, your application's view paths are checked. To check other directories instead, use the `viewDirectories` parameter: + +```neon +parameters: + checkUnusedViews: true + viewDirectories: + - resources/views + - modules/billing/resources/views +``` + + +### Console Input + +Calls to a command's `argument()` and `option()` methods are checked against the command's definition. Define the argument or option in the command's signature, or check `hasArgument()` or `hasOption()` before reading an input the command may not define: + +```php +if ($this->hasOption('force') && $this->option('force')) { + // ... +} +``` + + +### Authentication in Requests + +When the `checkAuthCallsWhenInRequestScope` parameter is enabled, the analysis recommends `$request->user()` over `Auth::user()` and `auth()->user()` where a request is available. The facade and helper remain fully supported. Since a request's user resolver may return a different user than the `Auth` facade, review each change instead of replacing calls mechanically. + + +### Redis Connections + +`Redis::connection()` returns a proxy that runs each command on a pooled connection, so methods that act on one connection, such as `client()`, `auth()` or `release()`, are reported. Call them on a held connection instead: + +```php +use Hypervel\Redis\RedisConnection; +use Hypervel\Support\Facades\Redis; + +Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); +}); +``` + +These checks may be disabled using the `checkRedisConnectionBoundMethods` parameter. + + +### Cancellation + +A failure thrown by a callback or a `finally` block can carry a [coroutine's cancellation](/docs/{{version}}/coroutines#coroutine-cancellation) as its previous exception. A `CanceledException` catch only matches cancellation thrown directly, so a later `Throwable` catch handles the canceled work as an ordinary failure: + +```php +try { + $order->sync(); +} catch (CanceledException $exception) { + // Catching CanceledException misses cancellation carried by another exception... + throw $exception; +} catch (Throwable $exception) { + report($exception); +} +``` + +Check `Coroutine::causedByCancellation` at the start of the `Throwable` catch instead: + +```php +use Hypervel\Coroutine\Coroutine; + +try { + $order->sync(); +} catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + report($exception); +} +``` + +Use the same approach instead of `instanceof CanceledException` or storing a directly caught cancellation for later. If the value may be `null`, such as `getPrevious()`, check `instanceof Throwable` first. A `Throwable` catch that only rethrows is not reported. Neither is a `try` block containing only waits that throw cancellation directly, such as channel waits or `Coroutine::join`. + +When you keep the first of several failures using `??=`, a later failure carrying cancellation is lost. Use `Coroutine::preferCancellation`, which keeps the first failure unless a later one carries this coroutine's cancellation: + +```php +$failure = Coroutine::preferCancellation($failure, $exception); +``` + +The check allows `??=` when no earlier failure can exist or the branch excludes cancellation. You may disable these checks using the `checkCancellationHandling` and `checkCancellationPrecedence` parameters. + + +### Coroutines in Transactions + +A database transaction belongs to the coroutine that started it. A coroutine started inside the transaction's callback borrows its own [pooled connection](/docs/{{version}}/database#connection-pooling), so its queries run outside the transaction: + +```php +DB::transaction(function () use ($order) { + $order->save(); + + // A coroutine started inside a database transaction runs outside it... + Coroutine::create(fn () => $this->notifyWarehouse($order)); +}); +``` + +Start the coroutine from a `DB::afterCommit` callback, or do the work in the transaction's own coroutine. The check covers `go`, `co`, `parallel`, `wait`, `Coroutine::create`, `Coroutine::createOwned`, `Coroutine::fork`, `Coroutine::forkOwned`, `Parallel`, `Concurrent`, `WaitConcurrent` and `Waiter`. It also covers `Concurrency::run` when the selected driver is known to be `coroutine`. You may disable it using the `noCoroutinesInTransactions` parameter. + + +### Sleeping + +Pause using the [`Sleep`](/docs/{{version}}/helpers#sleep) class instead of PHP's `sleep` and `usleep` functions, so tests can fake the delay: + +```php +use Hypervel\Support\Sleep; + +// Call Sleep::sleep() instead of sleep(), so tests can fake the delay. +sleep(1); + +Sleep::sleep(1); +``` + +Keep the native function only where real time must pass, such as while polling an external process, and ignore the diagnostic with a reason. You may disable this check using the `noRawSleepCalls` parameter. + + +### Worker State + +A Hypervel worker handles many requests and jobs, often at the same time, and the container [shares most services](/docs/{{version}}/container#resolution-lifecycles) for the worker's whole life. Anything a shared service or a static property keeps is seen by every later request the worker handles, and by any request running alongside it: + +```php +class InvoiceService +{ + protected ?Request $request = null; + + public function prepare(Request $request): void + { + // Every other request on this worker now sees this request... + $this->request = $request; + } +} +``` + +Pass request values to the methods that use them, or keep them in [CoroutineContext](/docs/{{version}}/coroutine-context). If the whole object belongs to one request, mark the class [`#[Scoped]`](/docs/{{version}}/container#scoped-attribute) so each request or job gets its own instance. The analysis reports: + +- A property of a shared service that keeps the current request, a response, a user, an uploaded file or a model, whether a method assigns it or the constructor receives it. (`hypervel.workerSharedState`) +- A shared middleware, controller or job that writes to its own properties while handling a request or running a job. (`hypervel.workerSharedState`) +- A static property written while handling a request or running a job, or one that keeps any of the values above. (`hypervel.staticStateMutation`) +- An array on a static property or shared service whose keys come from the request or user, such as `$this->visits[$request->ip()]`, since it can grow for as long as the worker runs. Use a cache store with an expiry instead. Keys limited to a small set of values, such as booleans or constants, are not reported, and neither is an array the same method has just reset. When the array is bounded in a way the analysis cannot see, ignore the diagnostic with a reason. (`hypervel.unboundedWorkerCache`) + +Request and job checks cover middleware, controller actions, route closures, form requests, and queued classes' `handle`, `__invoke` and `failed` methods, along with the methods they call directly on `$this`. Nested closures are not checked because they may run later. + +Shared services are identified from your application's container registrations, resolved classes, handlers and constructor dependencies, following the container's [resolution lifecycles](/docs/{{version}}/container#resolution-lifecycles). A constructor-injected model used as a fresh prototype is allowed. Objects created inside binding closures are not inspected, and job `handle` dependencies are followed only for jobs using `Dispatchable`. + +Methods that only run while the application boots or in tests may say so with a docblock line starting with "Boot-only", "Tests only" or "Boot or tests only". Writes in these methods, and in `flushState` methods, are not reported: + +```php +/** + * Set the request used to build URLs. + * + * Tests only. The request stays on the shared service until a test replaces it. + */ +public function setRequestForTesting(Request $request): void +{ + $this->request = $request; +} +``` + +Other worker state, such as a lazily created connection or a registry filled while the application boots, is not reported. When a reported write is intended, ignore it with a reason. You may disable these checks using the `checkWorkerSharedState`, `checkStaticStateMutation` and `checkUnboundedWorkerCaches` parameters. + + +### Runtime Configuration + +Configuration belongs to the whole worker, so changing it while handling a request changes it for every request the worker handles afterwards, and for any running at the same time: + +```php +public function handle(Request $request, Closure $next): Response +{ + // Every request on this worker now uses this locale... + config(['app.locale' => $request->user()->locale]); + + return $next($request); +} +``` + +Set configuration during application boot and keep per-request values in [CoroutineContext](/docs/{{version}}/coroutine-context). The check reports writes through the `config` helper, `Config` facade, container-resolved repository and supported method-injected parameters in the [request and job methods](#worker-state) described above. + +Only writes known to target the shared configuration are reported. Repositories you construct yourself or store in properties are not checked. You may disable the rule using the `noRuntimeConfigMutation` parameter. + + +### Model Relations + +Eager loading, relationship queries and relationship aggregates report a relation the model does not define. Eager loading and the `has`, `whereHas` and `whereRelation` families also check each segment of a nested path: + +```php +// Relation 'comment' is not found in App\Models\Post model. +$users = User::with('posts.comment')->get(); +``` + +A model method that requires arguments is reported, since loading a relation calls its method without any. A relation registered using `resolveRelationUsing` while the application boots is found. The segments after it are checked only when its resolver's return type names the related model. Relations on an abstract model or a union of models are not checked, since each model may define them differently. + +A model's default `$with` and `$withCount` eager loads are checked the same way. Defaults a model inherits are checked for each model that inherits them, since the model may define the relation itself. + + +### Model Columns + +Eloquent methods that accept a column, such as `where`, `firstWhere`, `value`, `pluck`, `create`, `update` and `fill`, are checked against the model's [properties](#model-properties) when the name you pass is statically known. The same applies to queries made through a relationship and to the attributes given to a factory: + +```php +// Parameter #1 $attributes of static method ...::create() expects array... +User::create([ + 'name' => 'Taylor', + 'emial' => 'taylor@example.com', +]); +``` + +A name qualified by a table, such as `users.email`, is checked against that table, and only the column of a `column as alias` name is checked. A dynamic where that names an unknown column, such as `whereEmial`, is reported as an undefined method. A name whose value is not statically known, such as an arbitrary string, is not checked, and neither are columns the analysis cannot know about, as described in [Model Properties](#model-properties). + +You may check the column arguments of your own code using the [`model-property`](#model-property) type. These checks may be disabled using the `checkModelProperties` parameter. + + +### Model Scopes and Accessors + +Eloquent calls local query scopes and accessors for you, so define scopes as protected methods marked with the `Scope` attribute, and accessors as protected methods: + +```php +use Hypervel\Database\Eloquent\Attributes\Scope; +use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\Casts\Attribute; + +#[Scope] +protected function popular(Builder $query): void +{ + $query->where('votes', '>', 100); +} + +protected function firstName(): Attribute +{ + return Attribute::get(fn (string $value) => ucfirst($value)); +} +``` + +The analysis reports a legacy `scopePopular` method, naming the attributed scope that replaces it, and a scope or accessor that is not protected. Legacy scopes and public methods keep working, while a private scope or accessor is never called by Eloquent. + +A scope declaring no return type, `void`, `null` or a builder remains chainable. Other return types are preserved; for example, `Builder|int` returns either the calling query or an integer. + +When the model already has a method with the scope's name, such as a notification's `read` method beside its `scopeRead` scope, the legacy scope is the only way to keep the scope's name, so it is not reported. A scope defined in a reusable trait should also keep its legacy name, since a model's own method with the same name would silently replace an attributed scope; ignore it with a reason. Likewise, ignore a public scope or accessor that other code calls directly as part of a public API. You may disable these checks using the `noLegacyModelScopes` and `checkModelMethodVisibility` parameters. + + +### Model Configuration + +Configure a model with attributes rather than properties, such as its table, keys, timestamps and mass assignment: + +```php +use Hypervel\Database\Eloquent\Attributes\Fillable; +use Hypervel\Database\Eloquent\Attributes\Hidden; +use Hypervel\Database\Eloquent\Attributes\Table; + +#[Table('flights', key: 'flight_id')] +#[Fillable(['name', 'airline'])] +#[Hidden(['secret'])] +class Flight extends Model +{ +} +``` + +The check recommends attributes for model settings such as the table, connection, keys, timestamps, mass assignment and serialization. It also identifies declarations that repeat an inherited or default value. Existing properties remain supported. Settings assigned directly by the constructor or through setters such as `setTable` are not reported; assignments inside helper methods or trait initializers are not detected. + +Review subclasses before converting a model. Attributes and properties have different inheritance behavior: a child's `Hidden` attribute replaces the parent's `Hidden` attribute, but adds to a parent's `$hidden` property. An inherited `WithoutTimestamps` attribute also overrides a child's `$timestamps = true` property. + +Keep properties when consumers rely on this behavior, especially on models that applications extend, and ignore the diagnostic with a reason. You may disable the check using `noLegacyModelProperties`. + + +### Appended Attributes + +A model serializes each attribute it appends, using the `Appends` attribute or the `$appends` property, through the attribute's get accessor or class cast. The analysis reports an appended attribute the model does not have, and a column with neither a get accessor nor a class cast: + +```php +use Hypervel\Database\Eloquent\Attributes\Appends; +use Hypervel\Database\Eloquent\Casts\Attribute; + +#[Appends(['is_admin'])] +class User extends Model +{ + protected function isAdmin(): Attribute + { + return Attribute::get(fn () => $this->role === 'admin'); + } +} +``` + +Attributes a model inherits are checked for each model that inherits them, since the model may define the accessor itself. Models that customize how attributes are serialized are not checked. You may disable this check using the `checkModelAppends` parameter. + + +### Creating Models + +A model's static `make` method builds a query for the model before creating it, so create the model using `new` instead: + +```php +// Called 'Model::make()' which performs unnecessary work, use 'new Model()'. +$user = User::make(['name' => 'Taylor']); + +$user = new User(['name' => 'Taylor']); +``` + +This suggestion applies only when direct construction preserves the behavior of `make`. Models using the default connection, global scopes such as `SoftDeletes`, boot methods or custom construction are excluded, as are calls through `static` in extensible classes. You may disable the check using `noModelMake`. + + +### Query Results + +Fetching every row only to count, test or pluck the results is slower than letting the database do it. The analysis reports a collection method called on a query's results when the query itself can return the same result: + +```php +// Called 'count' on Hypervel collection, but could have been retrieved as a query with 'count()'. +$active = Flight::where('active', 1)->get()->count(); + +$active = Flight::where('active', 1)->count(); +``` + +The check covers `first`, `count`, `isEmpty`, `isNotEmpty`, `take`, `pluck` and `modelKeys`. For `isEmpty` and `isNotEmpty`, the diagnostic suggests the query's `doesntExist` and `exists` methods. + +Only direct query chains with equivalent results are reported. Queries stored in variables, relationship queries, selected columns, scopes, macros and custom collection behavior are excluded. Accessors, casts, observers and other model customizations can also prevent a suggestion. Keep the collection call when moving it to the database would change its behavior. + +You may choose the checked methods using the `noUnnecessaryCollectionCallOnly` and `noUnnecessaryCollectionCallExcept` parameters, or disable the rule using the `noUnnecessaryCollectionCall` parameter. + + +### Starting Queries + +When the `noImplicitQueryBuilderCall` parameter is enabled, the analysis recommends starting a model query with `query()` instead of calling a builder method on the model directly: + +```php +// Call to static method App\Models\User::where() is forwarded to the query builder. +$users = User::where('active', 1)->get(); + +$users = User::query()->where('active', 1)->get(); +``` + +Calling builder methods on the model remains fully supported. Models that override `__call`, `__callStatic` or `query` to change how they forward these calls are not checked. + + +### Schema Dumps + +When a schema dump your models use cannot be read, such as a PostgreSQL dump in `pg_dump`'s custom archive format or a dump with a syntax error, the analysis reports it once, at the dump: + +```text +database/schema/pgsql-schema.dump:1: Model property types cannot use this schema dump: PostgreSQL archives must be exported as plain SQL before parsing. +``` + +Until the dump is fixed, the tables it defines are unknown, so their attributes read as `mixed`. Regenerate the dump using the `schema:dump` Artisan command, which writes plain SQL. A dump is only reported when the analyzed code reads a model attribute on its connection. You may stop reading schema dumps using the `disableSchemaScan` parameter. + + +### Dispatching Jobs and Events + +A job's `dispatch`, `dispatchIf`, `dispatchUnless`, `dispatchSync` and `dispatchAfterResponse` methods, and an event's `dispatch`, `dispatchIf`, `dispatchUnless` and `broadcast` methods, pass their arguments to the class's constructor, so the arguments are checked against it: + +```php +// Parameter #1 $podcast of job class App\Jobs\ProcessPodcast constructor expects App\Models\Podcast... +ProcessPodcast::dispatch($podcast->id); +``` + +The first argument of `dispatchIf` and `dispatchUnless` is the condition, so only the arguments after it are checked. A class that defines its own dispatch method is checked against that method instead. + + +### Dispatching Jobs in Transactions + +A job dispatched inside a database transaction may be processed before the transaction commits, so it may not find the data the transaction writes, or it may run even though the transaction rolls back. The analysis reports a job dispatched inside a `DB::transaction` callback when its queue connection does not wait for the commit: + +```php +DB::transaction(function () use ($order) { + $order->save(); + + // Job App\Jobs\ShipOrder is dispatched inside a transaction without deferring until commit. + ShipOrder::dispatch($order); +}); +``` + +Chain the `afterCommit` method onto the dispatch, implement the `ShouldQueueAfterCommit` interface on the job, or enable the [`after_commit` option](/docs/{{version}}/queues#jobs-and-database-transactions) on the queue connection. Jobs staged in the [transactional outbox](/docs/{{version}}/queues#transactional-outbox) are not reported, and neither are jobs queued by the database driver on the transaction's own connection, since their rows commit with the transaction. A dispatch that calls `beforeCommit` is a deliberate choice and is not reported either. + +The analysis reads the queue connection from the dispatch, the job's `Connection` attribute or `$connection` property, and your configuration files. When it can't be known, such as a connection read from an environment variable or a job whose constructor changes its own settings, the dispatch is not reported. You may disable this check using the `checkDispatchInTransactionAfterCommit` parameter. + + +### Models in Queued Jobs + +A queued job that holds Eloquent models or Eloquent collections in its properties should use the `SerializesModels` trait, which the `Hypervel\Foundation\Queue\Queueable` trait includes. Without it, the whole model is serialized onto the queue and the job runs with the model as it was when the job was dispatched. With it, only the model's identifier is queued, and the model is retrieved fresh when the job runs. + +Properties of any visibility are checked, including inherited ones. A job that defines its own `__serialize` method is not checked. You may disable this check using the `checkJobSerializesModels` parameter. + + +### Batched Jobs + +Every job you add to a batch must use the `Hypervel\Bus\Batchable` trait, or adding it fails. The analysis reports a job that does not use it when you pass it to `Bus::batch()` or to a batch's `add` method, including jobs held in variables and the jobs of a chain within the batch. A value that may hold different jobs, such as a job or a chain, or one of several lists of jobs, is reported only when every possibility includes a job without the trait. You may disable this check using the `checkBatchedJobIsBatchable` parameter. + +Jobs that implement `ShouldBeUnique` should be dispatched individually, since only an individual dispatch acquires the job's unique lock. `Bus::batch()`, `Bus::bulk()`, `Queue::bulk()` and a batch's `add` method queue jobs without it, so duplicates are queued. A later job of a chain within a batch acquires its lock when the chain reaches it, but when the lock is already held, the job is skipped and the batch never finishes. A value that may hold different jobs is reported when any possibility includes a unique job. You may disable this check using the `noBatchedUniqueJob` parameter. + +When the `checkBatchableJobChecksCancellation` parameter is enabled, the analysis also reports batchable jobs that do not check whether their batch has been canceled. Canceling a batch does not stop jobs that are already queued, so a job should return early when its batch has been canceled, or use the `SkipIfBatchCancelled` middleware: + +```php +public function handle(): void +{ + if ($this->batch()?->cancelled()) { + return; + } + + // ... +} +``` + +The analysis recognizes the batch's `cancelled` method, the job's `batching` method and the `SkipIfBatchCancelled` middleware in any of the job's methods, including inherited ones. It does not check that the job checks before doing any work. + + +### Unique Jobs + +A unique job's lock is identified by its class and its `uniqueId`. A job whose constructor accepts arguments, such as a job that updates one product, usually needs a separate lock for each set of arguments, so the analysis reports such a job unless it declares a public `uniqueId` method or property: + +```php +/** + * Get the unique ID for the job. + */ +public function uniqueId(): string +{ + return (string) $this->product->id; +} +``` + +When the job should be unique regardless of its arguments, return a constant from `uniqueId` to make that clear. You may disable this check using the `checkUniqueJobUniqueId` parameter. + +When the `checkUniqueJobUniqueFor` parameter is enabled, the analysis also reports unique jobs that do not declare how long they stay unique, using the `UniqueFor` attribute, a public `uniqueFor` method or a public `$uniqueFor` property. Without one, the lock is acquired without a duration, whose meaning depends on your cache store, so a worker that stops while running the job may leave the lock held. + + +### Queue Configuration + +Configure queued jobs, listeners, mailables, notifications and broadcast events with attributes rather than properties: + +```php +use Hypervel\Queue\Attributes\Backoff; +use Hypervel\Queue\Attributes\Queue; +use Hypervel\Queue\Attributes\Tries; + +#[Queue('podcasts')] +#[Tries(5)] +#[Backoff([1, 5, 10])] +class ProcessPodcast implements ShouldQueue +{ +} +``` + +The check recommends attributes for public queue settings, including the connection, queue, delay, retries and timeouts, wherever that class supports the attribute. It also identifies declarations that can be removed without changing the setting. Settings declared by a trait may be moved to an attribute on that trait unless their value depends on the consuming class. Existing properties remain supported. + +Computed settings, `false` flags, and settings assigned directly by the constructor or through `Queueable` setters are not reported. A `false` flag can disable an inherited attribute. Assignments inside other constructor-called methods are not detected. + +Review inheritance and other callers before converting a property. A subclass's non-null public property overrides an inherited attribute, while its own attribute replaces the parent's entire attribute; for example, `#[DebounceFor(10)]` does not inherit a parent's `maxWait`. Keep a property that other code reads or assigns if no parent provides it, and ignore the diagnostic with a reason. You may disable this check using `noLegacyQueueProperties`. + + +### Validation Rules + +List validation rules in arrays rather than pipe-separated strings: + +```php +$request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'code' => ['required', 'regex:/^(draft|published)$/'], +]); +``` + +The check covers rules passed to validation helpers and methods, form requests, data objects, conditional rules and prompts. Concatenated and interpolated strings are checked too. Run `phpstan analyse --fix` to convert unambiguous strings to arrays; rules with empty segments or surrounding whitespace require a manual change. + +A string holding a `regex` or `not_regex` rule is reported without a fix, because the validator also splits the pattern at its own `|`. Give the regex its own array element, where the validator keeps it whole. Data rules differ: a data rule string that starts with a regex rule is kept whole, so it is not reported. + +Rule arrays built in variables, helpers or closures are not followed. Constants and conditional values inside a directly supplied array are checked. You may disable the rule using `noPipeSeparatedValidationRules`. + + +### Deprecations + +The bundled deprecation rules report uses of deprecated APIs from any of your dependencies, including PHP, Symfony and Carbon as well as Hypervel. Use the replacement the deprecation names. When you still need the deprecated API, such as to keep a public API compatible, ignore it with the reported identifier and a reason. + +Code inside a deprecated method, class or trait may use other deprecated APIs without being reported. + +Deprecation checks do not cover methods and properties reached through proxies such as `$users->each`, `optional($user)` or `$user->when($active)`. + +To disable deprecation reporting, remove the `rules.neon` include. If you use the extension installer, add `phpstan/phpstan-deprecation-rules` to the `extra.phpstan/extension-installer.ignore` list in your `composer.json` file instead. diff --git a/src/doctor/composer.json b/src/doctor/composer.json index d970fd6d7c..4a29883bf3 100644 --- a/src/doctor/composer.json +++ b/src/doctor/composer.json @@ -33,6 +33,7 @@ "hypervel/conditionable": "^0.4", "hypervel/console": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/filesystem": "^0.4", "hypervel/foundation": "^0.4", diff --git a/src/doctor/src/DiagnosticSource.php b/src/doctor/src/DiagnosticSource.php index 63030f09b6..0a22588f34 100644 --- a/src/doctor/src/DiagnosticSource.php +++ b/src/doctor/src/DiagnosticSource.php @@ -5,6 +5,7 @@ namespace Hypervel\Doctor; use Composer\InstalledVersions; +use Hypervel\Support\Composer; use ReflectionClass; use Throwable; @@ -197,7 +198,7 @@ protected static function packagePath(string $package): ?string protected static function rootPackageName(): ?string { try { - return InstalledVersions::getRootPackage()['name']; + return Composer::rootPackage()['name']; } catch (Throwable) { return null; } diff --git a/src/doctor/src/Diagnostics/CacheStoreIsReachable.php b/src/doctor/src/Diagnostics/CacheStoreIsReachable.php index a6aeb0d493..b3ff1a5411 100644 --- a/src/doctor/src/Diagnostics/CacheStoreIsReachable.php +++ b/src/doctor/src/Diagnostics/CacheStoreIsReachable.php @@ -6,6 +6,7 @@ use Hypervel\Cache\Console\CacheTableCommand; use Hypervel\Cache\DatabaseStore; +use Hypervel\Coroutine\Coroutine; use Hypervel\Doctor\Contracts\Fixable; use Hypervel\Doctor\Diagnostic; use Hypervel\Doctor\EnvironmentMode; @@ -22,7 +23,6 @@ use Hypervel\Support\Facades\Schema; use Hypervel\Support\Str; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; @@ -81,9 +81,11 @@ public function check(): DiagnosticResult try { $alternatives = $this->failoverStores($store); $cleaned = $this->probe($store); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $failure = $exception; } @@ -94,9 +96,11 @@ public function check(): DiagnosticResult if (! $this->probe($alternative)) { $unhealthy[] = "[{$alternative}]: The probe key could not be removed."; } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $unhealthy[] = "[{$alternative}]: {$exception->getMessage()}"; } } @@ -155,9 +159,11 @@ public function fix(DiagnosticResult $result, ?string $option = null): FixResult if (! $this->probe($option)) { return $this->fixFailed('switch-failed')->withDetails('The probe key could not be removed.'); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return $this->fixFailed('switch-failed') ->withDetails($e->getMessage()); } @@ -202,9 +208,11 @@ private function fixOptions(string $failing): array if (! $this->probe($store)) { continue; } - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + if (! $needsTables) { continue; } @@ -240,9 +248,11 @@ private function cacheTableIsMissing(): bool return ! $schema->hasTable(CacheTableCommand::TABLE) && ! $schema->hasTable(DatabaseStore::DEFAULT_LOCK_TABLE); - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return false; } } @@ -306,12 +316,8 @@ private function probe(string $store): bool } finally { try { $cleaned = $repository->forget($key); - } catch (CanceledException $exception) { - if (! $failure instanceof CanceledException) { - $failure = $exception; - } } catch (Throwable $exception) { - $failure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } } diff --git a/src/doctor/src/Diagnostics/ConfigurationCanBeCached.php b/src/doctor/src/Diagnostics/ConfigurationCanBeCached.php index c8c3883506..ce7edc2d4b 100644 --- a/src/doctor/src/Diagnostics/ConfigurationCanBeCached.php +++ b/src/doctor/src/Diagnostics/ConfigurationCanBeCached.php @@ -4,13 +4,13 @@ namespace Hypervel\Doctor\Diagnostics; +use Hypervel\Coroutine\Coroutine; use Hypervel\Doctor\Diagnostic; use Hypervel\Doctor\Results\DiagnosticResult; use Hypervel\Doctor\Results\Link; use Hypervel\Doctor\Results\Message; use Hypervel\Support\Arr; use LogicException; -use Swoole\Coroutine\CanceledException; use Throwable; class ConfigurationCanBeCached extends Diagnostic @@ -42,9 +42,11 @@ public function check(): DiagnosticResult { try { $this->cacheConfiguration(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return $this->fail('cannot-cache') ->withDetails($e->getMessage()); } @@ -59,9 +61,11 @@ private function cacheConfiguration(): void { try { eval('return ' . var_export(config()->all(), true) . ';'); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + throw $this->serializationException($e); } } @@ -74,9 +78,11 @@ private function serializationException(Throwable $previous): Throwable foreach (Arr::dot(config()->all()) as $key => $value) { try { eval('return ' . var_export($value, true) . ';'); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return new LogicException( sprintf('The configuration value at [%s] is not serializable: %s', $key, $exception->getMessage()), 0, diff --git a/src/doctor/src/Diagnostics/ConfigurationFilesCanBeLoaded.php b/src/doctor/src/Diagnostics/ConfigurationFilesCanBeLoaded.php index 2b361e91d3..959fa105c1 100644 --- a/src/doctor/src/Diagnostics/ConfigurationFilesCanBeLoaded.php +++ b/src/doctor/src/Diagnostics/ConfigurationFilesCanBeLoaded.php @@ -4,11 +4,11 @@ namespace Hypervel\Doctor\Diagnostics; +use Hypervel\Coroutine\Coroutine; use Hypervel\Doctor\Diagnostic; use Hypervel\Doctor\Results\DiagnosticResult; use Hypervel\Doctor\Results\Link; use Hypervel\Doctor\Results\Message; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Finder\Finder; use Throwable; @@ -54,9 +54,11 @@ public function check(): DiagnosticResult foreach ($files as $file) { try { (fn (): mixed => require $file->getPathname())(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return $this->fail('load-failed') ->withDetails(sprintf('%s: %s', $file->getRelativePathname(), $e->getMessage())); } diff --git a/src/doctor/src/Diagnostics/DatabaseConnectionIsReachable.php b/src/doctor/src/Diagnostics/DatabaseConnectionIsReachable.php index 33c203345c..dcaadde1dd 100644 --- a/src/doctor/src/Diagnostics/DatabaseConnectionIsReachable.php +++ b/src/doctor/src/Diagnostics/DatabaseConnectionIsReachable.php @@ -4,6 +4,7 @@ namespace Hypervel\Doctor\Diagnostics; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionName; use Hypervel\Database\Connectors\ConnectionFactory; @@ -14,7 +15,6 @@ use Hypervel\Doctor\Results\Message; use Hypervel\Doctor\Support\Configured; use PDO; -use Swoole\Coroutine\CanceledException; use Throwable; class DatabaseConnectionIsReachable extends Diagnostic @@ -68,9 +68,11 @@ public function check(): DiagnosticResult return $this->fail('unreachable', ['connection' => $connection]) ->withDetails(implode(PHP_EOL, $failures)); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return $this->fail('unreachable', ['connection' => $connection]) ->withDetails($e->getMessage()); } @@ -93,9 +95,11 @@ private function probe(ConnectionName $name, array $configuration): array foreach ($this->probeConfigurations($factory, $name, $configuration) as $label => [$selected, $read]) { try { $this->probeConnection($factory, $name->base, $selected, $read); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $failures[] = ($label === '' ? '' : "[{$label}] ") . $exception->getMessage(); } } @@ -170,12 +174,8 @@ private function probeConnection(ConnectionFactory $factory, string $name, array } finally { try { $database->disconnect(); - } catch (CanceledException $exception) { - if (! $failure instanceof CanceledException) { - $failure = $exception; - } } catch (Throwable $exception) { - $failure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } } diff --git a/src/doctor/src/Diagnostics/FilesystemDisksAreReachable.php b/src/doctor/src/Diagnostics/FilesystemDisksAreReachable.php index 915bd9988e..3753d0debe 100644 --- a/src/doctor/src/Diagnostics/FilesystemDisksAreReachable.php +++ b/src/doctor/src/Diagnostics/FilesystemDisksAreReachable.php @@ -4,6 +4,7 @@ namespace Hypervel\Doctor\Diagnostics; +use Hypervel\Coroutine\Coroutine; use Hypervel\Doctor\Diagnostic; use Hypervel\Doctor\Results\DiagnosticResult; use Hypervel\Doctor\Results\Link; @@ -11,7 +12,6 @@ use Hypervel\Doctor\Support\Configured; use Hypervel\Support\Facades\Storage; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class FilesystemDisksAreReachable extends Diagnostic @@ -57,9 +57,11 @@ public function check(): DiagnosticResult try { $this->probe($disk, $configuration); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return $this->fail('unreachable', ['disk' => $disk]) ->withDetails($e->getMessage()); } diff --git a/src/doctor/src/Diagnostics/MigrationsAreUpToDate.php b/src/doctor/src/Diagnostics/MigrationsAreUpToDate.php index 8b3000e946..c82f8b567e 100644 --- a/src/doctor/src/Diagnostics/MigrationsAreUpToDate.php +++ b/src/doctor/src/Diagnostics/MigrationsAreUpToDate.php @@ -4,6 +4,7 @@ namespace Hypervel\Doctor\Diagnostics; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Migrations\Migrator; use Hypervel\Doctor\Contracts\Fixable; use Hypervel\Doctor\Diagnostic; @@ -14,7 +15,6 @@ use Hypervel\Doctor\Results\Message; use Hypervel\Doctor\Support\Details; use Hypervel\Support\Facades\Artisan; -use Swoole\Coroutine\CanceledException; use Throwable; class MigrationsAreUpToDate extends Diagnostic implements Fixable @@ -85,9 +85,11 @@ public function check(): DiagnosticResult ->fixable(EnvironmentMode::Local) ->withDetails(Details::bullets(array_map($migrator->getMigrationName(...), $pending))); }); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return $this->fail('inspection-failed') ->withDetails($e->getMessage()); } @@ -107,9 +109,11 @@ public function fix(DiagnosticResult $result, ?string $option = null): FixResult 'The migrate command exited without output.', )); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return $this->fixFailed('migration-failed') ->withDetails($exception->getMessage()); } diff --git a/src/doctor/src/Diagnostics/PublicStorageLinkExists.php b/src/doctor/src/Diagnostics/PublicStorageLinkExists.php index 9cec635d82..d2702f9dd5 100644 --- a/src/doctor/src/Diagnostics/PublicStorageLinkExists.php +++ b/src/doctor/src/Diagnostics/PublicStorageLinkExists.php @@ -5,6 +5,7 @@ namespace Hypervel\Doctor\Diagnostics; use FilesystemIterator; +use Hypervel\Coroutine\Coroutine; use Hypervel\Doctor\Contracts\Fixable; use Hypervel\Doctor\Diagnostic; use Hypervel\Doctor\Results\DiagnosticResult; @@ -13,7 +14,6 @@ use Hypervel\Doctor\Results\Message; use Hypervel\Doctor\Support\Details; use Hypervel\Support\Facades\Artisan; -use Swoole\Coroutine\CanceledException; use Throwable; class PublicStorageLinkExists extends Diagnostic implements Fixable @@ -100,9 +100,11 @@ public function fix(DiagnosticResult $result, ?string $option = null): FixResult try { $exitCode = Artisan::call('storage:link'); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return $this->fixFailed('creation-failed')->withDetails($exception->getMessage()); } diff --git a/src/doctor/src/Diagnostics/QueueConnectionIsReachable.php b/src/doctor/src/Diagnostics/QueueConnectionIsReachable.php index b1a5b816e4..0643a83788 100644 --- a/src/doctor/src/Diagnostics/QueueConnectionIsReachable.php +++ b/src/doctor/src/Diagnostics/QueueConnectionIsReachable.php @@ -4,6 +4,7 @@ namespace Hypervel\Doctor\Diagnostics; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Schema\Builder; use Hypervel\Doctor\Diagnostic; use Hypervel\Doctor\Results\DiagnosticResult; @@ -15,7 +16,6 @@ use Hypervel\Support\Facades\Redis; use Hypervel\Support\Facades\Schema; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class QueueConnectionIsReachable extends Diagnostic @@ -68,15 +68,19 @@ public function check(): DiagnosticResult foreach ($connections as $name) { try { $this->probe($name, $this->configuration($name)); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $unhealthy[] = "[{$name}]: {$exception->getMessage()}"; } } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return $this->fail('unreachable', ['connection' => $connection]) ->withDetails($e->getMessage()); } diff --git a/src/doctor/src/Diagnostics/RedisConnectionsAreReachable.php b/src/doctor/src/Diagnostics/RedisConnectionsAreReachable.php index 7cb232eeed..f892ec2154 100644 --- a/src/doctor/src/Diagnostics/RedisConnectionsAreReachable.php +++ b/src/doctor/src/Diagnostics/RedisConnectionsAreReachable.php @@ -4,6 +4,7 @@ namespace Hypervel\Doctor\Diagnostics; +use Hypervel\Coroutine\Coroutine; use Hypervel\Doctor\Diagnostic; use Hypervel\Doctor\Results\DiagnosticResult; use Hypervel\Doctor\Results\Link; @@ -11,7 +12,6 @@ use Hypervel\Doctor\Support\ActiveDrivers; use Hypervel\Doctor\Support\Details; use Hypervel\Support\Facades\Redis; -use Swoole\Coroutine\CanceledException; use Throwable; class RedisConnectionsAreReachable extends Diagnostic @@ -53,9 +53,11 @@ public function check(): DiagnosticResult foreach ($connections as $connection) { try { Redis::connection($connection)->ping(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + $failures[$connection] = $e->getMessage(); } } diff --git a/src/doctor/src/Diagnostics/RedisSupportsConfiguredFeatures.php b/src/doctor/src/Diagnostics/RedisSupportsConfiguredFeatures.php index f39a54518f..874adabc23 100644 --- a/src/doctor/src/Diagnostics/RedisSupportsConfiguredFeatures.php +++ b/src/doctor/src/Diagnostics/RedisSupportsConfiguredFeatures.php @@ -5,6 +5,7 @@ namespace Hypervel\Doctor\Diagnostics; use Hypervel\Cache\TagMode; +use Hypervel\Coroutine\Coroutine; use Hypervel\Doctor\Diagnostic; use Hypervel\Doctor\Results\DiagnosticResult; use Hypervel\Doctor\Results\Link; @@ -15,7 +16,6 @@ use Hypervel\Redis\RedisRequirements; use Hypervel\Session\SessionManager; use Hypervel\Support\Facades\Redis; -use Swoole\Coroutine\CanceledException; use Throwable; class RedisSupportsConfiguredFeatures extends Diagnostic @@ -79,9 +79,11 @@ public function check(): DiagnosticResult if ($error !== null) { $failures[$connection] = $error; } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + $failures[$connection] = $e->getMessage(); } } diff --git a/src/doctor/src/Diagnostics/SessionDriverIsReachable.php b/src/doctor/src/Diagnostics/SessionDriverIsReachable.php index fd1a774cfe..3fc34d9118 100644 --- a/src/doctor/src/Diagnostics/SessionDriverIsReachable.php +++ b/src/doctor/src/Diagnostics/SessionDriverIsReachable.php @@ -4,6 +4,7 @@ namespace Hypervel\Doctor\Diagnostics; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Schema\Builder; use Hypervel\Doctor\Diagnostic; use Hypervel\Doctor\Results\DiagnosticResult; @@ -14,7 +15,6 @@ use Hypervel\Support\Facades\Redis; use Hypervel\Support\Facades\Schema; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class SessionDriverIsReachable extends Diagnostic @@ -53,9 +53,11 @@ public function check(): DiagnosticResult try { $this->probe($driver); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return $this->fail('unreachable', ['driver' => $driver]) ->withDetails($e->getMessage()); } diff --git a/src/doctor/src/Doctor.php b/src/doctor/src/Doctor.php index 3f4adf1873..ffba7137c7 100644 --- a/src/doctor/src/Doctor.php +++ b/src/doctor/src/Doctor.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Contracts\Container\Container; +use Hypervel\Coroutine\Coroutine; use Hypervel\Doctor\Contracts\Fixable; use Hypervel\Doctor\Results\DiagnosticFixOutcome; use Hypervel\Doctor\Results\DiagnosticOutcome; @@ -15,7 +16,6 @@ use Hypervel\Support\Collection; use InvalidArgumentException; use LogicException; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -181,9 +181,11 @@ public function check(Diagnostic $diagnostic): DiagnosticOutcome { try { $result = $diagnostic->check(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + $result = DiagnosticResult::error($e->getMessage()) ->withContext('exception', $e::class); } @@ -222,9 +224,11 @@ public function fix(DiagnosticOutcome $outcome, ?string $option = null): Diagnos try { $result = $diagnostic->fix($outcome->result, $option); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + $result = FixResult::error( sprintf('Failed to fix %s: %s', $diagnostic->name, $e->getMessage()), )->withContext('exception', $e::class); diff --git a/src/encryption/src/Encrypter.php b/src/encryption/src/Encrypter.php index fb703bee16..6b6c9bda62 100644 --- a/src/encryption/src/Encrypter.php +++ b/src/encryption/src/Encrypter.php @@ -340,6 +340,8 @@ public function getPreviousKeys(): array * * Boot-only. Mutates the previous-key list; the encrypter is registered as * a container singleton, so per-request use races across coroutines. + * + * @return $this */ public function previousKeys(#[SensitiveParameter] array $keys): static { diff --git a/src/engine/src/Coroutine.php b/src/engine/src/Coroutine.php index 705ce7a3a3..82fa7d83cc 100644 --- a/src/engine/src/Coroutine.php +++ b/src/engine/src/Coroutine.php @@ -163,7 +163,6 @@ public static function isCanceled(): bool return false; } - /* @phpstan-ignore staticMethod.notFound (@TODO: Remove once PHPStan's bundled JetBrains Swoole stub includes Swoole 6.2's isCanceled() method.) */ return SwooleCo::isCanceled(); } diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php index 2aae255214..108d9b5e63 100755 --- a/src/events/src/Dispatcher.php +++ b/src/events/src/Dispatcher.php @@ -421,6 +421,8 @@ public function until(object|string $event, mixed $payload = []): mixed /** * Fire an event and call the listeners. + * + * @return ($halt is true ? mixed : null|list) */ public function dispatch(object|string $event, mixed $payload = [], bool $halt = false): mixed { @@ -1160,6 +1162,7 @@ protected function queueRoutesContainer(): ContainerContract * worker lifetime and is consulted whenever a queued listener is dispatched. * * @param callable(): QueueFactory $resolver + * @return $this */ public function setQueueResolver(callable $resolver): static { @@ -1187,6 +1190,7 @@ protected function resolveTransactionManager(): ?DatabaseTransactionsManager * worker lifetime and is consulted for every transaction-aware dispatch. * * @param callable(): ?DatabaseTransactionsManager $resolver + * @return $this */ public function setTransactionManagerResolver(callable $resolver): static { diff --git a/src/events/src/QueuedClosure.php b/src/events/src/QueuedClosure.php index 86739c22c5..81ec5ef76a 100644 --- a/src/events/src/QueuedClosure.php +++ b/src/events/src/QueuedClosure.php @@ -62,6 +62,8 @@ public function __construct(Closure $closure) /** * Set the desired connection for the job. + * + * @return $this */ public function onConnection(UnitEnum|string|null $connection): static { @@ -74,6 +76,8 @@ public function onConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the job. + * + * @return $this */ public function onQueue(UnitEnum|string|null $queue): static { @@ -88,6 +92,8 @@ public function onQueue(UnitEnum|string|null $queue): static * Set the desired job "group". * * This feature is only supported by some queues, such as Amazon SQS. + * + * @return $this */ public function onGroup(UnitEnum|string $group): static { @@ -100,6 +106,8 @@ public function onGroup(UnitEnum|string $group): static * Set the desired job deduplicator callback. * * This feature is only supported by some queues, such as Amazon SQS FIFO. + * + * @return $this */ public function withDeduplicator(?callable $deduplicator): static { @@ -112,6 +120,8 @@ public function withDeduplicator(?callable $deduplicator): static /** * Set the desired delay in seconds for the job. + * + * @return $this */ public function delay(DateTimeInterface|DateInterval|int|null $delay): static { @@ -122,6 +132,8 @@ public function delay(DateTimeInterface|DateInterval|int|null $delay): static /** * Specify a callback that should be invoked if the queued listener job fails. + * + * @return $this */ public function catch(Closure $closure): static { diff --git a/src/facade-documenter/facade.php b/src/facade-documenter/facade.php index eff26c3ea5..1f23bf2d69 100755 --- a/src/facade-documenter/facade.php +++ b/src/facade-documenter/facade.php @@ -695,6 +695,10 @@ function resolveDocblockTypes($method, $typeNode, int $depth = 1) $if = resolveDocblockTypes($method, $typeNode->if, $depth + 1); $else = resolveDocblockTypes($method, $typeNode->else, $depth + 1); + if ($if === $else) { + return $if; + } + if (! canPreserveConditionalTarget($method, $typeNode->targetType)) { return flattenConditionalBranches($if, $else); } diff --git a/src/filesystem/composer.json b/src/filesystem/composer.json index d848a0c8d4..05eb34d767 100644 --- a/src/filesystem/composer.json +++ b/src/filesystem/composer.json @@ -44,8 +44,8 @@ "league/flysystem-read-only": "^3.25.1", "psr/http-message": "^2.0", "symfony/finder": "^8.1", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "conflict": { "league/flysystem-ftp": "<3.13", @@ -57,7 +57,7 @@ "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", "league/flysystem-google-cloud-storage": "Required to use the Flysystem Google Cloud Storage driver (^3.25.1).", "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", - "symfony/filesystem": "Required to use relative symbolic links (^8.1).", + "symfony/filesystem": "Required to use relative symbolic links (^8.1.11).", "symfony/mime": "Required to enable support for guessing extensions (^8.1)." }, "autoload": { diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php index 3137f7a3e4..abdae95f89 100644 --- a/src/filesystem/src/Filesystem.php +++ b/src/filesystem/src/Filesystem.php @@ -7,6 +7,7 @@ use ErrorException; use FilesystemIterator; use Hypervel\Contracts\Filesystem\FileNotFoundException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Json; use Hypervel\Support\LazyCollection; use Hypervel\Support\Traits\Conditionable; @@ -116,7 +117,7 @@ public function sharedGet(string $path): string throw new RuntimeException("Unable to release the shared lock for file at path [{$path}]."); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -125,7 +126,7 @@ public function sharedGet(string $path): string throw new RuntimeException("Unable to close file at path [{$path}]."); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index 814b65e61b..064e8fa9cc 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -126,6 +126,8 @@ public function __construct( /** * Assert that the given file or directory exists. + * + * @return $this */ public function assertExists(array|string $path, ?string $content = null): static { @@ -155,6 +157,8 @@ public function assertExists(array|string $path, ?string $content = null): stati /** * Assert that the number of files in path equals the expected count. + * + * @return $this */ public function assertCount(string $path, int $count, bool $recursive = false): static { @@ -173,6 +177,8 @@ public function assertCount(string $path, int $count, bool $recursive = false): /** * Assert that the given file or directory does not exist. + * + * @return $this */ public function assertMissing(array|string $path): static { @@ -192,6 +198,8 @@ public function assertMissing(array|string $path): static /** * Assert that the given directory is empty. + * + * @return $this */ public function assertDirectoryEmpty(string $path): static { @@ -205,6 +213,8 @@ public function assertDirectoryEmpty(string $path): static /** * Assert that the disk contains no files. + * + * @return $this */ public function assertEmpty(): static { diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 1dd164b868..80e8552fb5 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -371,6 +371,8 @@ protected function diskPoolDefinition( /** * Create an instance of the local driver. + * + * @return LocalFilesystemAdapter */ public function createLocalDriver(array $config, string $name = 'local'): Filesystem { @@ -405,6 +407,8 @@ public function createLocalDriver(array $config, string $name = 'local'): Filesy /** * Create an instance of the ftp driver. + * + * @return FilesystemAdapter */ public function createFtpDriver(array $config): Filesystem { @@ -422,6 +426,8 @@ public function createFtpDriver(array $config): Filesystem /** * Create an instance of the sftp driver. + * + * @return FilesystemAdapter */ public function createSftpDriver(array $config): Filesystem { @@ -445,6 +451,8 @@ public function createSftpDriver(array $config): Filesystem /** * Create an instance of the Amazon S3 driver. + * + * @return AwsS3V3Adapter */ public function createS3Driver(array $config): Cloud { @@ -456,6 +464,8 @@ public function createS3Driver(array $config): Cloud /** * Create an instance of the read-through driver. + * + * @return ReadThroughFilesystem */ public function createReadThroughDriver(array $config, string $name = 'read-through'): Filesystem { @@ -669,6 +679,8 @@ protected function s3CredentialProvider(mixed $credentials): mixed /** * Create an instance of the Google Cloud Storage driver. + * + * @return GoogleCloudStorageAdapter */ public function createGcsDriver(array $config): Cloud { @@ -978,6 +990,8 @@ protected function createFlysystem(FlysystemAdapter $adapter, array $config): Fi * coroutines may already hold a reference to the prior disk and next * resolution will return the replacement. Any shared pool remains * available until its idle TTL expires or purge() invalidates it. + * + * @return $this */ public function set(UnitEnum|string $name, mixed $disk): static { @@ -1025,6 +1039,7 @@ public function getDefaultDriver(): string * TTL expires or purge() deliberately invalidates them. * * @param array|string|UnitEnum $disk + * @return $this */ public function forgetDisk(UnitEnum|array|string $disk): static { @@ -1097,6 +1112,8 @@ public function extend(string $driver, Closure $callback, bool $poolable = false * * Tests only. Swaps the singleton's container reference; per-request use * races across coroutines and breaks every concurrent filesystem operation. + * + * @return $this */ public function setApplication(Container $app): static { diff --git a/src/filesystem/src/LocalFilesystemAdapter.php b/src/filesystem/src/LocalFilesystemAdapter.php index 709d766d74..6a681e32b7 100644 --- a/src/filesystem/src/LocalFilesystemAdapter.php +++ b/src/filesystem/src/LocalFilesystemAdapter.php @@ -146,6 +146,8 @@ private function unsupportedTemporaryUrlException(bool $upload = false): Runtime /** * Specify the name of the disk the adapter is managing. + * + * @return $this */ public function diskName(string $disk): static { @@ -159,6 +161,8 @@ public function diskName(string $disk): static * * Boot-only. The route metadata persists on the cached adapter for the * worker lifetime and affects every subsequent signed URL request. + * + * @return $this */ public function servingRoute(?string $disk, string $prefix = ''): static { @@ -175,6 +179,8 @@ public function servingRoute(?string $disk, string $prefix = ''): static * Boot-only. The flag and resolver persist on the cached local disk adapter * for the worker lifetime and apply to every subsequent signed URL request * for that disk. + * + * @return $this */ public function shouldServeSignedUrls(bool $serve = true, ?Closure $urlGeneratorResolver = null): static { diff --git a/src/filesystem/src/LockableFile.php b/src/filesystem/src/LockableFile.php index f1d637998b..cdad2751f4 100644 --- a/src/filesystem/src/LockableFile.php +++ b/src/filesystem/src/LockableFile.php @@ -5,6 +5,7 @@ namespace Hypervel\Filesystem; use Hypervel\Contracts\Filesystem\LockTimeoutException; +use Hypervel\Coroutine\Coroutine; use RuntimeException; use Throwable; @@ -110,6 +111,8 @@ public function wasDeleted(): bool /** * Write to the file. + * + * @return $this */ public function write(string $contents): static { @@ -132,6 +135,8 @@ public function write(string $contents): static /** * Truncate the file. + * + * @return $this */ public function truncate(): static { @@ -184,6 +189,8 @@ public function getExclusiveLock(bool $block = false): static /** * Release the lock on the file. + * + * @return $this */ public function releaseLock(): static { @@ -218,7 +225,7 @@ public function close(): bool throw new RuntimeException("Unable to close file at path [{$this->path}]."); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/filesystem/src/ReadThroughFilesystem.php b/src/filesystem/src/ReadThroughFilesystem.php index 1ccf6cc972..26a72c7a70 100644 --- a/src/filesystem/src/ReadThroughFilesystem.php +++ b/src/filesystem/src/ReadThroughFilesystem.php @@ -57,7 +57,7 @@ public function temporaryUrl(string $path, DateTimeInterface $expiration, array { return isset($this->temporaryUrlCallback) ? ($this->temporaryUrlCallback)($path, $expiration, $options) - : $this->readerFor($path)->temporaryUrl($this->readThroughPrefixer->prefixPath($path), $expiration, $options); // @phpstan-ignore method.notFound + : $this->readerFor($path)->temporaryUrl($this->readThroughPrefixer->prefixPath($path), $expiration, $options); // @phpstan-ignore method.notFound (adapter method outside the Cloud contract) } /** @@ -65,7 +65,7 @@ public function temporaryUrl(string $path, DateTimeInterface $expiration, array */ public function providesTemporaryUploadUrls(): bool { - return isset($this->temporaryUploadUrlCallback) || $this->primary->providesTemporaryUploadUrls(); // @phpstan-ignore method.notFound + return isset($this->temporaryUploadUrlCallback) || $this->primary->providesTemporaryUploadUrls(); // @phpstan-ignore method.notFound (adapter method outside the Cloud contract) } /** @@ -75,7 +75,7 @@ public function temporaryUploadUrl(string $path, DateTimeInterface $expiration, { return isset($this->temporaryUploadUrlCallback) ? ($this->temporaryUploadUrlCallback)($path, $expiration, $options) - : $this->primary->temporaryUploadUrl($this->readThroughPrefixer->prefixPath($path), $expiration, $options); // @phpstan-ignore method.notFound + : $this->primary->temporaryUploadUrl($this->readThroughPrefixer->prefixPath($path), $expiration, $options); // @phpstan-ignore method.notFound (adapter method outside the Cloud contract) } /** @@ -84,8 +84,8 @@ public function temporaryUploadUrl(string $path, DateTimeInterface $expiration, public function providesTemporaryUrls(): bool { return isset($this->temporaryUrlCallback) - || $this->primary->providesTemporaryUrls() // @phpstan-ignore method.notFound - || $this->fallback->providesTemporaryUrls(); // @phpstan-ignore method.notFound + || $this->primary->providesTemporaryUrls() // @phpstan-ignore method.notFound (adapter method outside the Cloud contract) + || $this->fallback->providesTemporaryUrls(); // @phpstan-ignore method.notFound (adapter method outside the Cloud contract) } /** diff --git a/src/filesystem/src/ServeFile.php b/src/filesystem/src/ServeFile.php index c6a14491c0..b7488b6655 100644 --- a/src/filesystem/src/ServeFile.php +++ b/src/filesystem/src/ServeFile.php @@ -41,8 +41,7 @@ public function __invoke(Request $request, string $path): Response ]; return tap( - // The contract omits adapter response methods, which every shipped disk provides. - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (the contract omits adapter response methods, which every shipped disk provides) $disk->serve($request, $path, headers: $headers), function (Response $response) use ($headers): void { if (! $response->headers->has('Content-Security-Policy')) { diff --git a/src/fortify/composer.json b/src/fortify/composer.json index 2140b0d52a..dc00d849f9 100644 --- a/src/fortify/composer.json +++ b/src/fortify/composer.json @@ -51,7 +51,7 @@ "psr/clock": "^1.0", "spomky-labs/otphp": "^11.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php b/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php index 954775542a..43fb058d68 100644 --- a/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php +++ b/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php @@ -40,7 +40,7 @@ public function show(Request $request): ConfirmPasswordViewResponse public function store(Request $request): Responsable { $request->validate([ - 'password' => 'nullable|string', + 'password' => ['nullable', 'string'], ]); $guardName = Fortify::guardName(); diff --git a/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php b/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php index fc3f8e8b2f..1cfafebad8 100644 --- a/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php +++ b/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php @@ -4,7 +4,6 @@ namespace Hypervel\Fortify\Http\Controllers; -use Hypervel\Contracts\Auth\MustVerifyEmail; use Hypervel\Contracts\Support\Responsable; use Hypervel\Fortify\Contracts\EmailVerificationNotificationSentResponse; use Hypervel\Fortify\Http\Responses\RedirectAsIntended; @@ -19,7 +18,6 @@ class EmailVerificationNotificationController extends Controller */ public function store(Request $request): JsonResponse|Responsable { - /** @var MustVerifyEmail $user */ $user = $request->user(); if ($user->hasVerifiedEmail()) { diff --git a/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php b/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php index 5d39f84c4b..6cd57837c1 100644 --- a/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php +++ b/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php @@ -4,7 +4,6 @@ namespace Hypervel\Fortify\Http\Controllers; -use Hypervel\Contracts\Auth\MustVerifyEmail; use Hypervel\Contracts\Support\Responsable; use Hypervel\Fortify\Contracts\VerifyEmailViewResponse; use Hypervel\Fortify\Http\Responses\RedirectAsIntended; @@ -18,7 +17,6 @@ class EmailVerificationPromptController extends Controller */ public function __invoke(Request $request): Responsable { - /** @var MustVerifyEmail $user */ $user = $request->user(); return $user->hasVerifiedEmail() diff --git a/src/fortify/src/Http/Controllers/NewPasswordController.php b/src/fortify/src/Http/Controllers/NewPasswordController.php index 25d0d650fd..a3d830cd0a 100644 --- a/src/fortify/src/Http/Controllers/NewPasswordController.php +++ b/src/fortify/src/Http/Controllers/NewPasswordController.php @@ -55,7 +55,7 @@ public function store(Request $request): Responsable $request->validate([ 'token' => 'required', - Fortify::email() => 'required|email', + Fortify::email() => ['required', 'email'], 'password' => 'required', ]); diff --git a/src/fortify/src/Http/Controllers/VerifyEmailController.php b/src/fortify/src/Http/Controllers/VerifyEmailController.php index 7ad14b6f6f..f8a9e70558 100644 --- a/src/fortify/src/Http/Controllers/VerifyEmailController.php +++ b/src/fortify/src/Http/Controllers/VerifyEmailController.php @@ -5,7 +5,6 @@ namespace Hypervel\Fortify\Http\Controllers; use Hypervel\Auth\Events\Verified; -use Hypervel\Contracts\Auth\MustVerifyEmail; use Hypervel\Fortify\Concerns\DispatchesEvents; use Hypervel\Fortify\Contracts\VerifyEmailResponse; use Hypervel\Fortify\Http\Requests\VerifyEmailRequest; @@ -20,7 +19,6 @@ class VerifyEmailController extends Controller */ public function __invoke(VerifyEmailRequest $request): VerifyEmailResponse { - /** @var MustVerifyEmail $user */ $user = $request->user(); if ($user->hasVerifiedEmail()) { diff --git a/src/fortify/src/Http/Requests/LoginRequest.php b/src/fortify/src/Http/Requests/LoginRequest.php index 17b6844cb5..7ed7de4e11 100644 --- a/src/fortify/src/Http/Requests/LoginRequest.php +++ b/src/fortify/src/Http/Requests/LoginRequest.php @@ -20,14 +20,14 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. * - * @return array + * @return array> */ public function rules(): array { return [ - Fortify::username() => 'required|string', - 'password' => 'required|string', - 'remember' => 'sometimes', + Fortify::username() => ['required', 'string'], + 'password' => ['required', 'string'], + 'remember' => ['sometimes'], ]; } } diff --git a/src/fortify/src/Http/Requests/SendPasswordResetLinkRequest.php b/src/fortify/src/Http/Requests/SendPasswordResetLinkRequest.php index 969775a12f..8d5a35980b 100644 --- a/src/fortify/src/Http/Requests/SendPasswordResetLinkRequest.php +++ b/src/fortify/src/Http/Requests/SendPasswordResetLinkRequest.php @@ -20,12 +20,12 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. * - * @return array + * @return array> */ public function rules(): array { return [ - Fortify::email() => 'required|email', + Fortify::email() => ['required', 'email'], ]; } } diff --git a/src/fortify/src/Http/Requests/TwoFactorLoginRequest.php b/src/fortify/src/Http/Requests/TwoFactorLoginRequest.php index 334505940c..df081af5d3 100644 --- a/src/fortify/src/Http/Requests/TwoFactorLoginRequest.php +++ b/src/fortify/src/Http/Requests/TwoFactorLoginRequest.php @@ -34,13 +34,13 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. * - * @return array + * @return array> */ public function rules(): array { return [ - 'code' => 'nullable|string', - 'recovery_code' => 'nullable|string', + 'code' => ['nullable', 'string'], + 'recovery_code' => ['nullable', 'string'], ]; } diff --git a/src/foundation/composer.json b/src/foundation/composer.json index ad92e0d11c..6c178aa8ec 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -38,8 +38,8 @@ "symfony/console": "^8.1.10", "symfony/error-handler": "^8.1", "symfony/finder": "^8.1", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/process": "^8.1", "symfony/var-dumper": "^8.1", "vlucas/phpdotenv": "^5.6.1", @@ -84,9 +84,6 @@ "hypervel/view": "^0.4", "hypervel/websocket-server": "^0.4" }, - "require-dev": { - "phpstan/phpstan": "2.3.1" - }, "suggest": { "algolia/algoliasearch-client-php": "Required to use the InteractsWithAlgolia trait (^4.49.0).", "composer/semver": "Required to use PackageManifest::satisfies() for version constraint checking (^3.0).", @@ -108,11 +105,6 @@ "Hypervel\\Foundation\\Providers\\FoundationServiceProvider" ] }, - "phpstan": { - "includes": [ - "extension.neon" - ] - }, "branch-alias": { "dev-main": "0.4-dev" } diff --git a/src/foundation/config/auth.php b/src/foundation/config/auth.php index 15f84aa809..1dfe48df4e 100644 --- a/src/foundation/config/auth.php +++ b/src/foundation/config/auth.php @@ -100,7 +100,7 @@ 'providers' => [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), // @phpstan-ignore class.notFound + 'model' => env('AUTH_MODEL', App\Models\User::class), // @phpstan-ignore class.notFound (the application's default user model) /* |------------------------------------------------------------------ diff --git a/src/foundation/extension.neon b/src/foundation/extension.neon deleted file mode 100644 index d19a913b08..0000000000 --- a/src/foundation/extension.neon +++ /dev/null @@ -1,5 +0,0 @@ -services: - - - class: Hypervel\Foundation\PHPStan\HelperFunctionReturnTypeExtension - tags: - - phpstan.broker.dynamicFunctionReturnTypeExtension diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index a7e317258a..e1be94cdd0 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -250,6 +250,7 @@ public static function inferBasePath(): string return match (true) { isset($_ENV['APP_BASE_PATH']) => $_ENV['APP_BASE_PATH'], isset($_SERVER['APP_BASE_PATH']) => $_SERVER['APP_BASE_PATH'], + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the base path is needed before the environment file and configuration load) is_string($basePath = Env::get('APP_BASE_PATH')) => $basePath, default => dirname(array_values(array_filter( array_keys(ClassLoader::getRegisteredLoaders()), @@ -428,6 +429,8 @@ public function path(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useAppPath(string $path): static { @@ -459,6 +462,8 @@ public function bootstrapPath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useBootstrapPath(string $path): static { @@ -490,6 +495,8 @@ public function configPath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useConfigPath(string $path): static { @@ -513,6 +520,8 @@ public function databasePath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useDatabasePath(string $path): static { @@ -536,6 +545,8 @@ public function langPath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useLangPath(string $path): static { @@ -559,6 +570,8 @@ public function publicPath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function usePublicPath(string $path): static { @@ -615,6 +628,8 @@ public function storagePath(string $path = ''): string * * Boot-only. The configured path and its container binding are shared by * every request in the worker. + * + * @return $this */ public function useStoragePath(string $path): static { @@ -753,6 +768,7 @@ public function getCachedEventsPath(): string */ protected function normalizeCachePath(string $key, string $default): string { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (cache paths are read before configuration loads, including the configuration cache's own) if (is_null($env = Env::get($key))) { return $this->bootstrapPath($default); } @@ -791,7 +807,9 @@ public function joinPaths(?string $basePath, string $path = ''): string public function environment(UnitEnum|array|string ...$environments): bool|string { if ($environments !== []) { - $patterns = is_array($environments[0]) ? $environments[0] : $environments; + // Hypervel: a named environments: argument is keyed by name, so read the first value, not key 0. + $first = $environments[array_key_first($environments)]; + $patterns = is_array($first) ? $first : $environments; return Str::is(array_map(enum_value(...), $patterns), $this->make('env')); } @@ -839,6 +857,7 @@ public function runningInConsole(): bool return $this->isRunningInConsole; } + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the server command sets this in the process environment its workers inherit) return $this->isRunningInConsole = (bool) (Env::get('APP_RUNNING_IN_CONSOLE') ?? true); } diff --git a/src/foundation/src/Bootstrap/HandleExceptions.php b/src/foundation/src/Bootstrap/HandleExceptions.php index 45563a0fe3..111fab5bd1 100644 --- a/src/foundation/src/Bootstrap/HandleExceptions.php +++ b/src/foundation/src/Bootstrap/HandleExceptions.php @@ -191,6 +191,7 @@ protected function shouldIgnoreDeprecationErrors(): bool return ! class_exists(LogManager::class) || static::$app === null || ! static::$app->hasBeenBootstrapped() + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the test runner sets this for the test process) || (static::$app->runningUnitTests() && ! Env::get('LOG_DEPRECATIONS_WHILE_TESTING')); } diff --git a/src/foundation/src/Bootstrap/LoadConfiguration.php b/src/foundation/src/Bootstrap/LoadConfiguration.php index e52e35cd79..f48e815d14 100644 --- a/src/foundation/src/Bootstrap/LoadConfiguration.php +++ b/src/foundation/src/Bootstrap/LoadConfiguration.php @@ -111,7 +111,9 @@ protected function loadConfigurationFiles(Application $app, RepositoryContract $ ? $this->getBaseConfiguration() : []; - foreach ((new Collection($base))->diffKeys($files) as $name => $config) { + $baseOnly = (new Collection($base))->diffKeys($files)->all(); + + foreach ($baseOnly as $name => $config) { $repository->set($name, $config); } @@ -119,8 +121,13 @@ protected function loadConfigurationFiles(Application $app, RepositoryContract $ $base = $this->loadConfigurationFile($repository, $name, $path, $base); } + // Unlike Laravel, an unchanged base-only entry is not set again: it is already set, + // and setting it again discards the nested files loaded below it. Entries an + // overridden loadConfigurationFile() adds or changes still apply. foreach ($base as $name => $config) { - $repository->set($name, $config); + if (! array_key_exists($name, $baseOnly) || $baseOnly[$name] !== $config) { + $repository->set($name, $config); + } } } @@ -226,6 +233,15 @@ public static function frameworkConfigPath(): string return $path; } + /** + * Get the directory of the templates config:publish uses in place of framework + * configuration files when the framework configuration is merged. + */ + public static function frameworkConfigStubPath(): string + { + return dirname(__DIR__, 2) . '/config-stubs'; + } + /** * Get the base configuration files. * diff --git a/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php b/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php index f7a4dd741e..9536a0a352 100644 --- a/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php +++ b/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php @@ -45,6 +45,7 @@ protected function checkForSpecificEnvironmentFile(Application $app): void return; } + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (selects the environment file before it loads) $environment = Env::get('APP_ENV'); if (! $environment) { diff --git a/src/foundation/src/Bus/Dispatchable.php b/src/foundation/src/Bus/Dispatchable.php index 299cf3f531..b01f65691f 100644 --- a/src/foundation/src/Bus/Dispatchable.php +++ b/src/foundation/src/Bus/Dispatchable.php @@ -29,12 +29,12 @@ public static function dispatchIf(bool|Closure $boolean, mixed ...$arguments): F if ($boolean instanceof Closure) { $dispatchable = new static(...$arguments); - return value($boolean, $dispatchable) // @phpstan-ignore ternary.alwaysTrue (phpstan over-narrows TValue from conditional return PHPDoc) + return value($boolean, $dispatchable) ? static::newPendingDispatch($dispatchable) : new Fluent; } - return value($boolean) + return $boolean ? static::newPendingDispatch(new static(...$arguments)) : new Fluent; } @@ -47,12 +47,12 @@ public static function dispatchUnless(bool|Closure $boolean, mixed ...$arguments if ($boolean instanceof Closure) { $dispatchable = new static(...$arguments); - return ! value($boolean, $dispatchable) // @phpstan-ignore booleanNot.alwaysFalse + return ! value($boolean, $dispatchable) ? static::newPendingDispatch($dispatchable) : new Fluent; } - return ! value($boolean) + return ! $boolean ? static::newPendingDispatch(new static(...$arguments)) : new Fluent; } diff --git a/src/foundation/src/Bus/PendingChain.php b/src/foundation/src/Bus/PendingChain.php index 8bd4f3f0e3..f3345f67ea 100644 --- a/src/foundation/src/Bus/PendingChain.php +++ b/src/foundation/src/Bus/PendingChain.php @@ -7,9 +7,13 @@ use Closure; use DateInterval; use DateTimeInterface; +use Hypervel\Bus\ChainDefaults; use Hypervel\Bus\ChainedBatch; use Hypervel\Container\Container; use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Queue\Attributes\Connection; +use Hypervel\Queue\Attributes\Delay; +use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Queue\CallQueuedClosure; use Hypervel\Support\Collection; use Hypervel\Support\Traits\Conditionable; @@ -57,6 +61,8 @@ public function __construct( /** * Set the desired connection for the job. + * + * @return $this */ public function onConnection(UnitEnum|string|null $connection): static { @@ -69,6 +75,8 @@ public function onConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the job. + * + * @return $this */ public function onQueue(UnitEnum|string|null $queue): static { @@ -81,6 +89,8 @@ public function onQueue(UnitEnum|string|null $queue): static /** * Prepend a job to the chain. + * + * @return $this */ public function prepend(mixed $job): static { @@ -101,6 +111,8 @@ public function prepend(mixed $job): static /** * Append a job to the chain. + * + * @return $this */ public function append(mixed $job): static { @@ -119,6 +131,8 @@ public function append(mixed $job): static /** * Set the desired delay in seconds for the chain. + * + * @return $this */ public function delay(DateInterval|DateTimeInterface|int|null $delay): static { @@ -129,6 +143,8 @@ public function delay(DateInterval|DateTimeInterface|int|null $delay): static /** * Add a callback to be executed on job failure. + * + * @return $this */ public function catch(callable $callback): static { @@ -151,6 +167,16 @@ public function catchCallbacks(): array * Dispatch the job chain. */ public function dispatch(mixed ...$arguments): mixed + { + return Container::getInstance() + ->make(Dispatcher::class) + ->dispatch($this->prepareFirstJob($arguments)); + } + + /** + * Prepare the first job of the chain for dispatch. + */ + protected function prepareFirstJob(array $arguments): object { if (is_string($this->job)) { $firstJob = new $this->job(...$arguments); @@ -160,30 +186,34 @@ public function dispatch(mixed ...$arguments): mixed $firstJob = $this->job; } + // Laravel checks only the job's properties, so a chain default replaced the job's own + // #[Connection], #[Queue] or #[Delay]. The job's own attribute or property beats the chain default. + $defaults = new ChainDefaults; + if ($this->connection !== null && $this->connection !== '') { $firstJob->chainConnection = $this->connection; - $firstJob->connection = $firstJob->connection === null || $firstJob->connection === '' - ? $this->connection - : $firstJob->connection; + + if ($defaults->appliesTo($firstJob, Connection::class, 'connection')) { + $firstJob->connection = $this->connection; + } } if ($this->queue !== null && $this->queue !== '') { $firstJob->chainQueue = $this->queue; - $firstJob->queue = $firstJob->queue === null || $firstJob->queue === '' - ? $this->queue - : $firstJob->queue; + + if ($defaults->appliesTo($firstJob, QueueAttribute::class, 'queue')) { + $firstJob->queue = $this->queue; + } } - if ($this->delay) { - $firstJob->delay = ! is_null($firstJob->delay) ? $firstJob->delay : $this->delay; + if ($this->delay && $defaults->appliesTo($firstJob, Delay::class, 'delay')) { + $firstJob->delay = $this->delay; } $firstJob->chain($this->chain); $firstJob->chainCatchCallbacks = $this->catchCallbacks(); - return Container::getInstance() - ->make(Dispatcher::class) - ->dispatch($firstJob); + return $firstJob; } /** diff --git a/src/foundation/src/Bus/PendingClosureDispatch.php b/src/foundation/src/Bus/PendingClosureDispatch.php index 00a7d2e1b3..dbe764f67a 100644 --- a/src/foundation/src/Bus/PendingClosureDispatch.php +++ b/src/foundation/src/Bus/PendingClosureDispatch.php @@ -10,6 +10,8 @@ class PendingClosureDispatch extends PendingDispatch { /** * Add a callback to be executed if the job fails. + * + * @return $this */ public function catch(Closure $callback): static { diff --git a/src/foundation/src/Bus/PendingDispatch.php b/src/foundation/src/Bus/PendingDispatch.php index 1f0bf3d271..221f439072 100644 --- a/src/foundation/src/Bus/PendingDispatch.php +++ b/src/foundation/src/Bus/PendingDispatch.php @@ -42,6 +42,8 @@ public function __construct( /** * Set the desired connection for the job. + * + * @return $this */ public function onConnection(UnitEnum|string|null $connection): static { @@ -52,6 +54,8 @@ public function onConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the job. + * + * @return $this */ public function onQueue(UnitEnum|string|null $queue): static { @@ -64,6 +68,8 @@ public function onQueue(UnitEnum|string|null $queue): static * Set the desired job message group. * * This feature is only supported by some queues, such as Amazon SQS. + * + * @return $this */ public function onGroup(array|UnitEnum|string|int|null $group): static { @@ -78,6 +84,8 @@ public function onGroup(array|UnitEnum|string|int|null $group): static * Set the desired job deduplicator callback. * * This feature is only supported by some queues, such as Amazon SQS FIFO. + * + * @return $this */ public function withDeduplicator(array|callable|null $deduplicator): static { @@ -88,6 +96,8 @@ public function withDeduplicator(array|callable|null $deduplicator): static /** * Set the desired connection for the chain. + * + * @return $this */ public function allOnConnection(UnitEnum|string|null $connection): static { @@ -98,6 +108,8 @@ public function allOnConnection(UnitEnum|string|null $connection): static /** * Set the desired queue for the chain. + * + * @return $this */ public function allOnQueue(UnitEnum|string|null $queue): static { @@ -108,6 +120,8 @@ public function allOnQueue(UnitEnum|string|null $queue): static /** * Set the desired delay in seconds for the job. + * + * @return $this */ public function delay(DateInterval|DateTimeInterface|int|null $delay): static { @@ -118,6 +132,8 @@ public function delay(DateInterval|DateTimeInterface|int|null $delay): static /** * Set the delay for the job to zero seconds. + * + * @return $this */ public function withoutDelay(): static { @@ -128,6 +144,8 @@ public function withoutDelay(): static /** * Indicate that the job should be dispatched after the open parent database transactions have committed. + * + * @return $this */ public function afterCommit(): static { @@ -138,6 +156,8 @@ public function afterCommit(): static /** * Indicate that the job should not wait until database transactions have been committed before dispatching. + * + * @return $this */ public function beforeCommit(): static { @@ -148,6 +168,8 @@ public function beforeCommit(): static /** * Dispatch the job through the transactional outbox. + * + * @return $this */ public function viaOutbox(): static { @@ -158,6 +180,8 @@ public function viaOutbox(): static /** * Dispatch the job without the transactional outbox. + * + * @return $this */ public function withoutOutbox(): static { @@ -168,6 +192,8 @@ public function withoutOutbox(): static /** * Stage the job on the given database connection. + * + * @return $this */ public function onOutboxDatabase(UnitEnum|string $connection): static { @@ -178,6 +204,8 @@ public function onOutboxDatabase(UnitEnum|string $connection): static /** * Set the jobs that should run if this job is successful. + * + * @return $this */ public function chain(array $chain): static { @@ -188,6 +216,8 @@ public function chain(array $chain): static /** * Indicate that the job should be dispatched after the response is sent to the browser. + * + * @return $this */ public function afterResponse(bool $afterResponse = true): static { @@ -265,6 +295,8 @@ protected function acquireDebounceLock(): void /** * Dynamically proxy methods to the underlying job. + * + * @return $this */ public function __call(string $method, array $parameters): static { diff --git a/src/foundation/src/Concerns/ResolvesSourceHref.php b/src/foundation/src/Concerns/ResolvesSourceHref.php index fa79715526..21512f4a72 100644 --- a/src/foundation/src/Concerns/ResolvesSourceHref.php +++ b/src/foundation/src/Concerns/ResolvesSourceHref.php @@ -4,8 +4,8 @@ namespace Hypervel\Foundation\Concerns; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Str; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -51,10 +51,10 @@ protected function resolveSourceHref(string $file, ?int $line): ?string { try { $editor = config('app.editor'); - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { - // .. + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } } if (! isset($editor)) { diff --git a/src/foundation/src/Configuration/ApplicationBuilder.php b/src/foundation/src/Configuration/ApplicationBuilder.php index 6ef826b47b..f7bb0873cb 100644 --- a/src/foundation/src/Configuration/ApplicationBuilder.php +++ b/src/foundation/src/Configuration/ApplicationBuilder.php @@ -47,6 +47,8 @@ public function __construct(protected Application $app) * Boot-only. Re-registering the kernels replaces shared bindings and can * discard middleware or console configuration used by subsequent requests * and commands. + * + * @return $this */ public function withKernels(): static { @@ -71,6 +73,8 @@ public function withKernels(): static /** * Register additional service providers. + * + * @return $this */ public function withProviders(array $providers = [], bool $withBootstrapProviders = true): static { @@ -88,6 +92,7 @@ public function withProviders(array $providers = [], bool $withBootstrapProvider * Register the core event service provider for the application. * * @param bool|iterable $discover + * @return $this */ public function withEvents(iterable|bool $discover = true): static { @@ -112,6 +117,8 @@ public function withEvents(iterable|bool $discover = true): static /** * Register the broadcasting services for the application. + * + * @return $this */ public function withBroadcasting(string $channels, array $attributes = []): static { @@ -128,6 +135,8 @@ public function withBroadcasting(string $channels, array $attributes = []): stat /** * Register the routing services for the application. + * + * @return $this */ public function withRouting( ?Closure $using = null, @@ -217,6 +226,8 @@ protected function buildRoutingCallback( /** * Register the global middleware, middleware groups, and middleware aliases for the application. + * + * @return $this */ public function withMiddleware(?callable $callback = null): static { @@ -260,6 +271,8 @@ public function withMiddleware(?callable $callback = null): static /** * Register additional Artisan commands with the application. + * + * @return $this */ public function withCommands(array $commands = []): static { @@ -283,6 +296,8 @@ public function withCommands(array $commands = []): static /** * Register additional Artisan route paths. + * + * @return $this */ protected function withCommandRouting(array $paths): static { @@ -295,6 +310,8 @@ protected function withCommandRouting(array $paths): static /** * Register the scheduled tasks for the application. + * + * @return $this */ public function withSchedule(callable $callback): static { @@ -316,6 +333,7 @@ public function withSchedule(callable $callback): static * in the application container and affect subsequent requests. * * @param null|(callable(Exceptions): mixed) $using + * @return $this */ public function withExceptions(?callable $using = null): static { @@ -337,6 +355,8 @@ public function withExceptions(?callable $using = null): static /** * Register an array of container bindings to be bound when the application is booting. + * + * @return $this */ public function withBindings(array $bindings): static { @@ -349,6 +369,8 @@ public function withBindings(array $bindings): static /** * Register an array of singleton container bindings to be bound when the application is booting. + * + * @return $this */ public function withSingletons(array $singletons): static { @@ -365,6 +387,8 @@ public function withSingletons(array $singletons): static /** * Register an array of scoped singleton container bindings to be bound when the application is booting. + * + * @return $this */ public function withScopedSingletons(array $scopedSingletons): static { @@ -381,6 +405,8 @@ public function withScopedSingletons(array $scopedSingletons): static /** * Globally prefer JSON responses when the incoming "Accept" header is broad. + * + * @return $this */ public function prefersJsonResponses(bool $prefer = true): static { @@ -397,6 +423,8 @@ public function prefersJsonResponses(bool $prefer = true): static /** * Register a callback to be invoked when the application's service providers are registered. + * + * @return $this */ public function registered(callable $callback): static { @@ -407,6 +435,8 @@ public function registered(callable $callback): static /** * Register a callback to be invoked when the application is "booting". + * + * @return $this */ public function booting(callable $callback): static { @@ -417,6 +447,8 @@ public function booting(callable $callback): static /** * Register a callback to be invoked when the application is "booted". + * + * @return $this */ public function booted(callable $callback): static { diff --git a/src/foundation/src/Configuration/Exceptions.php b/src/foundation/src/Configuration/Exceptions.php index 29f88678ab..5c80b32e92 100644 --- a/src/foundation/src/Configuration/Exceptions.php +++ b/src/foundation/src/Configuration/Exceptions.php @@ -51,6 +51,8 @@ public function reportable(callable $reportUsing): ReportableHandler * * Boot-only. The callback persists on the shared handler and affects exception * rendering for every subsequent request in the worker. + * + * @return $this */ public function render(callable $using): static { @@ -64,6 +66,8 @@ public function render(callable $using): static * * Boot-only. The callback persists on the shared handler and affects exception * rendering for every subsequent request in the worker. + * + * @return $this */ public function renderable(callable $renderUsing): static { @@ -77,6 +81,8 @@ public function renderable(callable $renderUsing): static * * Boot-only. The callback replaces the shared handler's response callback for * every subsequent request in the worker. + * + * @return $this */ public function respond(callable $using): static { @@ -92,6 +98,7 @@ public function respond(callable $using): static * Callback return values are ignored. * * @param callable(Response, Throwable, Request): mixed $callback + * @return $this */ public function beforeResponding(callable $callback): static { @@ -105,6 +112,8 @@ public function beforeResponding(callable $callback): static * * Boot-only. The callback persists on the shared handler and affects exception * reporting for every subsequent request and job in the worker. + * + * @return $this */ public function throttle(callable $throttleUsing): static { @@ -119,6 +128,8 @@ public function throttle(callable $throttleUsing): static * Boot-only. The mapping persists on the shared handler and affects exception * reporting and rendering for every subsequent request and job in the worker. * + * @return $this + * * @throws InvalidArgumentException */ public function map(Closure|string $from, Closure|string|null $to = null): static @@ -136,6 +147,7 @@ public function map(Closure|string $from, Closure|string|null $to = null): stati * * @param class-string $type * @param LogLevel::* $level + * @return $this */ public function level(string $type, string $level): static { @@ -149,6 +161,8 @@ public function level(string $type, string $level): static * * Boot-only. The closure persists on the shared handler and runs for every * subsequently logged exception in the worker. + * + * @return $this */ public function context(Closure $contextCallback): static { @@ -162,6 +176,8 @@ public function context(Closure $contextCallback): static * * Boot-only. The exception types persist on the shared handler and affect * exception reporting for every subsequent request and job in the worker. + * + * @return $this */ public function dontReport(array|string $class): static { @@ -177,6 +193,8 @@ public function dontReport(array|string $class): static * * Boot-only. The exception types persist on the shared handler and affect * every subsequently failed job in the worker. + * + * @return $this */ public function dontRetry(array|string $class): static { @@ -194,6 +212,7 @@ public function dontRetry(array|string $class): static * reporting for every subsequent request and job in the worker. * * @param (Closure(Throwable): bool) $dontReportWhen + * @return $this */ public function dontReportWhen(Closure $dontReportWhen): static { @@ -211,6 +230,7 @@ public function dontReportWhen(Closure $dontReportWhen): static * @template TException of Throwable * * @param Closure(TException): bool $dontRetryWhen + * @return $this */ public function dontRetryWhen(Closure $dontRetryWhen): static { @@ -224,6 +244,8 @@ public function dontRetryWhen(Closure $dontRetryWhen): static * * Boot-only. The setting persists on the shared handler and applies to every * subsequent request and job in the worker. + * + * @return $this */ public function dontReportDuplicates(): static { @@ -237,6 +259,8 @@ public function dontReportDuplicates(): static * * Boot-only. The attributes persist on the shared handler and are omitted * from every subsequent validation redirect in the worker. + * + * @return $this */ public function dontFlash(array|string $attributes): static { @@ -250,6 +274,8 @@ public function dontFlash(array|string $attributes): static * * Boot-only. The callable replaces the shared handler's JSON check for every * subsequent request in the worker. + * + * @return $this */ public function shouldRenderJsonWhen(callable $callback): static { @@ -265,6 +291,7 @@ public function shouldRenderJsonWhen(callable $callback): static * exception reporting for every subsequent request and job in the worker. * * @param array>|class-string $class + * @return $this */ public function stopIgnoring(array|string $class): static { @@ -278,6 +305,8 @@ public function stopIgnoring(array|string $class): static * * Boot-only. The global default persists for the worker lifetime across all * coroutines; per-request truncation settings take precedence. + * + * @return $this */ public function truncateRequestExceptionsAt(int $length): static { @@ -291,6 +320,8 @@ public function truncateRequestExceptionsAt(int $length): static * * Boot-only. The global default persists for the worker lifetime across all * coroutines; per-request truncation settings take precedence. + * + * @return $this */ public function dontTruncateRequestExceptions(): static { diff --git a/src/foundation/src/Configuration/Middleware.php b/src/foundation/src/Configuration/Middleware.php index 53c2e9432c..334c4a31c7 100644 --- a/src/foundation/src/Configuration/Middleware.php +++ b/src/foundation/src/Configuration/Middleware.php @@ -113,6 +113,8 @@ class Middleware /** * Prepend middleware to the application's global middleware stack. + * + * @return $this */ public function prepend(array|string $middleware): static { @@ -126,6 +128,8 @@ public function prepend(array|string $middleware): static /** * Append middleware to the application's global middleware stack. + * + * @return $this */ public function append(array|string $middleware): static { @@ -139,6 +143,8 @@ public function append(array|string $middleware): static /** * Remove middleware from the application's global middleware stack. + * + * @return $this */ public function remove(array|string $middleware): static { @@ -152,6 +158,8 @@ public function remove(array|string $middleware): static /** * Specify a middleware that should be replaced with another middleware. + * + * @return $this */ public function replace(string $search, array|string $replace): static { @@ -162,6 +170,8 @@ public function replace(string $search, array|string $replace): static /** * Insert middleware before the given middleware in the global middleware stack. + * + * @return $this */ public function insertBefore(string $search, array|string $middleware): static { @@ -173,6 +183,8 @@ public function insertBefore(string $search, array|string $middleware): static /** * Insert middleware after the given middleware in the global middleware stack. + * + * @return $this */ public function insertAfter(string $search, array|string $middleware): static { @@ -184,6 +196,8 @@ public function insertAfter(string $search, array|string $middleware): static /** * Define the global middleware for the application. + * + * @return $this */ public function use(array $middleware): static { @@ -194,6 +208,8 @@ public function use(array $middleware): static /** * Define a middleware group. + * + * @return $this */ public function group(string $group, array $middleware): static { @@ -204,6 +220,8 @@ public function group(string $group, array $middleware): static /** * Prepend the given middleware to the specified group. + * + * @return $this */ public function prependToGroup(string $group, array|string $middleware): static { @@ -217,6 +235,8 @@ public function prependToGroup(string $group, array|string $middleware): static /** * Append the given middleware to the specified group. + * + * @return $this */ public function appendToGroup(string $group, array|string $middleware): static { @@ -230,6 +250,8 @@ public function appendToGroup(string $group, array|string $middleware): static /** * Remove the given middleware from the specified group. + * + * @return $this */ public function removeFromGroup(string $group, array|string $middleware): static { @@ -243,6 +265,8 @@ public function removeFromGroup(string $group, array|string $middleware): static /** * Replace the given middleware in the specified group with another middleware. + * + * @return $this */ public function replaceInGroup(string $group, string $search, array|string $replace): static { @@ -253,6 +277,8 @@ public function replaceInGroup(string $group, string $search, array|string $repl /** * Insert middleware before the given middleware in the specified group. + * + * @return $this */ public function insertBeforeInGroup(string $group, string $search, array|string $middleware): static { @@ -264,6 +290,8 @@ public function insertBeforeInGroup(string $group, string $search, array|string /** * Insert middleware after the given middleware in the specified group. + * + * @return $this */ public function insertAfterInGroup(string $group, string $search, array|string $middleware): static { @@ -275,6 +303,8 @@ public function insertAfterInGroup(string $group, string $search, array|string $ /** * Modify the middleware in the "web" group. + * + * @return $this */ public function web(array|string $append = [], array|string $prepend = [], array|string $remove = [], array $replace = []): static { @@ -283,6 +313,8 @@ public function web(array|string $append = [], array|string $prepend = [], array /** * Modify the middleware in the "api" group. + * + * @return $this */ public function api(array|string $append = [], array|string $prepend = [], array|string $remove = [], array $replace = []): static { @@ -291,6 +323,8 @@ public function api(array|string $append = [], array|string $prepend = [], array /** * Modify the middleware in the given group. + * + * @return $this */ protected function modifyGroup(string $group, array|string $append, array|string $prepend, array|string $remove, array $replace): static { @@ -319,6 +353,8 @@ protected function modifyGroup(string $group, array|string $append, array|string /** * Register additional middleware aliases. + * + * @return $this */ public function alias(array $aliases): static { @@ -329,6 +365,8 @@ public function alias(array $aliases): static /** * Define the middleware priority for the application. + * + * @return $this */ public function priority(array $priority): static { @@ -339,6 +377,8 @@ public function priority(array $priority): static /** * Prepend middleware to the priority middleware. + * + * @return $this */ public function prependToPriorityList(array|string $before, string $prepend): static { @@ -349,6 +389,8 @@ public function prependToPriorityList(array|string $before, string $prepend): st /** * Append middleware to the priority middleware. + * + * @return $this */ public function appendToPriorityList(array|string $after, string $append): static { @@ -444,6 +486,8 @@ public function getMiddlewareGroups(): array * Boot-only. The callback persists in authentication middleware and * exception static properties for the worker lifetime and affects every * subsequent unauthenticated or session-mismatch request. + * + * @return $this */ public function redirectGuestsTo(callable|string|null $redirect): static { @@ -458,6 +502,8 @@ public function redirectGuestsTo(callable|string|null $redirect): static * Boot-only. The callback persists in the guest middleware static property * for the worker lifetime and affects every subsequent already-authenticated * guest-route request. + * + * @return $this */ public function redirectUsersTo(callable|string $redirect): static { @@ -470,6 +516,8 @@ public function redirectUsersTo(callable|string $redirect): static * Boot-only. The callbacks persist in authentication middleware and * exception static properties for the worker lifetime and affect every * subsequent matching request. + * + * @return $this */ public function redirectTo(callable|string|null $guests = null, callable|string|null $users = null): static { @@ -483,6 +531,7 @@ public function redirectTo(callable|string|null $guests = null, callable|string| * * @param array $except * @param array $only + * @return $this */ public function encryptCookies(array $except = [], array $only = []): static { @@ -499,6 +548,8 @@ public function encryptCookies(array $except = [], array $only = []): static /** * Configure the request forgery prevention middleware. + * + * @return $this */ public function preventRequestForgery(array $except = [], bool $originOnly = false, bool $allowSameSite = false): static { @@ -519,6 +570,7 @@ public function preventRequestForgery(array $except = [], bool $originOnly = fal * Configure the URL signature validation middleware. * * @param array $except + * @return $this */ public function validateSignatures(array $except = []): static { @@ -531,6 +583,7 @@ public function validateSignatures(array $except = []): static * Configure the empty string conversion middleware. * * @param array $except + * @return $this */ public function convertEmptyStringsToNull(array $except = []): static { @@ -543,6 +596,7 @@ public function convertEmptyStringsToNull(array $except = []): static * Configure the string trimming middleware. * * @param array $except + * @return $this */ public function trimStrings(array $except = []): static { @@ -559,6 +613,7 @@ public function trimStrings(array $except = []): static * Indicate that the trusted host middleware should be enabled. * * @param null|array|(callable(): array) $at + * @return $this */ public function trustHosts(array|callable|null $at = null, bool $subdomains = true): static { @@ -575,6 +630,7 @@ public function trustHosts(array|callable|null $at = null, bool $subdomains = tr * Configure the trusted proxies for the application. * * @param null|array|string $at + * @return $this */ public function trustProxies(array|string|null $at = null, ?int $headers = null): static { @@ -593,6 +649,7 @@ public function trustProxies(array|string|null $at = null, ?int $headers = null) * Configure the middleware that prevents requests during maintenance mode. * * @param array $except + * @return $this */ public function preventRequestsDuringMaintenance(array $except = []): static { @@ -603,6 +660,8 @@ public function preventRequestsDuringMaintenance(array $except = []): static /** * Indicate that Sanctum's frontend state middleware should be enabled. + * + * @return $this */ public function statefulApi(): static { @@ -613,6 +672,8 @@ public function statefulApi(): static /** * Indicate that the API middleware group's throttling middleware should be enabled. + * + * @return $this */ public function throttleApi(string $limiter = 'api'): static { @@ -626,6 +687,8 @@ public function throttleApi(string $limiter = 'api'): static /** * Indicate that sessions should be authenticated for the "web" middleware group. + * + * @return $this */ public function authenticateSessions(): static { diff --git a/src/foundation/src/Console/AboutCommand.php b/src/foundation/src/Console/AboutCommand.php index 73d3277191..2613c1613e 100644 --- a/src/foundation/src/Console/AboutCommand.php +++ b/src/foundation/src/Console/AboutCommand.php @@ -247,7 +247,7 @@ protected function determineStoragePathLinkStatus(callable $formatStorageLinkedS return [public_path($path) => static::format(is_link($link), console: $formatStorageLinkedStatus)]; }) - ->toArray(); + ->all(); } /** diff --git a/src/foundation/src/Console/ChannelListCommand.php b/src/foundation/src/Console/ChannelListCommand.php index b098d4c6da..0de80ab1c1 100644 --- a/src/foundation/src/Console/ChannelListCommand.php +++ b/src/foundation/src/Console/ChannelListCommand.php @@ -92,7 +92,7 @@ protected function forCli(Collection $channels): array return $lines ->prepend('') ->push('')->push($channelCount)->push('') - ->toArray(); + ->all(); } /** diff --git a/src/foundation/src/Console/CliDumper.php b/src/foundation/src/Console/CliDumper.php index 85321db4b5..4fdd284008 100644 --- a/src/foundation/src/Console/CliDumper.php +++ b/src/foundation/src/Console/CliDumper.php @@ -52,7 +52,7 @@ public function __construct( */ public static function register(string $basePath, string $compiledViewPath): void { - $cloner = tap(new VarCloner)->addCasters(ReflectionCaster::UNSET_CLOSURE_FILE_INFO); // @phpstan-ignore method.notFound (tap proxy __call) + $cloner = tap(new VarCloner)->addCasters(ReflectionCaster::UNSET_CLOSURE_FILE_INFO); $dumper = new static(new ConsoleOutput, $basePath, $compiledViewPath); diff --git a/src/foundation/src/Console/ClosureCommand.php b/src/foundation/src/Console/ClosureCommand.php index f82510f578..59359d55e3 100644 --- a/src/foundation/src/Console/ClosureCommand.php +++ b/src/foundation/src/Console/ClosureCommand.php @@ -54,6 +54,8 @@ public function handle(): int /** * Set the description for the command. + * + * @return $this */ public function purpose(string $description): static { @@ -62,6 +64,8 @@ public function purpose(string $description): static /** * Set the description for the command. + * + * @return $this */ public function describe(string $description): static { diff --git a/src/foundation/src/Console/ConfigCacheCommand.php b/src/foundation/src/Console/ConfigCacheCommand.php index 43e7bd793c..5599dd85c9 100644 --- a/src/foundation/src/Console/ConfigCacheCommand.php +++ b/src/foundation/src/Console/ConfigCacheCommand.php @@ -5,10 +5,11 @@ namespace Hypervel\Foundation\Console; use Hypervel\Console\Command; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\PackageManifest; use Hypervel\Support\Arr; use LogicException; -use ReflectionClass; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\ArgvInput; @@ -174,7 +175,7 @@ protected function getFreshConfigurationCacheContentsFromSubprocess(): string throw $throwable; } finally { - $cleanupException = null; + $failure = $exception; foreach ([$dumpPath, $buildPath] as $path) { if (! is_string($path)) { @@ -186,12 +187,12 @@ protected function getFreshConfigurationCacheContentsFromSubprocess(): string throw new RuntimeException("Unable to delete the temporary configuration cache file [{$path}]."); } } catch (Throwable $throwable) { - $cleanupException ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } } - if ($exception === null && $cleanupException !== null) { - throw $cleanupException; + if ($failure !== $exception) { + throw $failure; } } } @@ -256,20 +257,12 @@ protected function buildFreshConfigurationCacheContents(): string */ protected function resolveSubprocessAutoloadPath(): string { - $componentRoot = dirname((new ReflectionClass(Filesystem::class))->getFileName(), 4); + $autoloadPath = $this->hypervel->make(PackageManifest::class)->vendorPath . '/autoload.php'; - $candidates = array_unique([ - $this->hypervel->basePath('vendor/autoload.php'), - $componentRoot . '/vendor/autoload.php', - dirname($componentRoot, 2) . '/autoload.php', - ]); - - foreach ($candidates as $candidate) { - if (is_file($candidate)) { - return $candidate; - } + if (! is_file($autoloadPath)) { + throw new LogicException('Unable to locate the Composer autoloader for the configuration cache subprocess.'); } - throw new LogicException('Unable to locate the Composer autoloader for the configuration cache subprocess.'); + return $autoloadPath; } } diff --git a/src/foundation/src/Console/ConfigPublishCommand.php b/src/foundation/src/Console/ConfigPublishCommand.php index 27c01f4099..22d1b19ef3 100644 --- a/src/foundation/src/Console/ConfigPublishCommand.php +++ b/src/foundation/src/Console/ConfigPublishCommand.php @@ -6,6 +6,7 @@ use Hypervel\Console\Command; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\Bootstrap\LoadConfiguration; use Hypervel\Support\Collection; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; @@ -103,11 +104,11 @@ protected function getBaseConfigurationFiles(): array $shouldMergeConfiguration = $this->hypervel->shouldMergeFrameworkConfiguration(); - foreach (Finder::create()->files()->name('*.php')->in(__DIR__ . '/../../config') as $file) { + foreach (Finder::create()->files()->name('*.php')->in(LoadConfiguration::frameworkConfigPath()) as $file) { $path = $file->getPathname(); $name = basename($path, '.php'); - $config[$name] = ($shouldMergeConfiguration === true && file_exists($stubPath = (__DIR__ . '/../../config-stubs/' . $name . '.php'))) + $config[$name] = ($shouldMergeConfiguration === true && file_exists($stubPath = LoadConfiguration::frameworkConfigStubPath() . '/' . $name . '.php')) ? $stubPath : $path; } diff --git a/src/foundation/src/Console/DownCommand.php b/src/foundation/src/Console/DownCommand.php index bdb62e9b03..55e88718c2 100644 --- a/src/foundation/src/Console/DownCommand.php +++ b/src/foundation/src/Console/DownCommand.php @@ -7,6 +7,7 @@ use Exception; use Hypervel\Console\Command; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Console\Concerns\ReloadsWorkers; use Hypervel\Foundation\Events\MaintenanceModeEnabled; use Hypervel\Foundation\Exceptions\RegisterErrorViewPaths; @@ -70,7 +71,7 @@ public function handle(): int try { $this->reloadWorkers(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/foundation/src/Console/EnvironmentDecryptCommand.php b/src/foundation/src/Console/EnvironmentDecryptCommand.php index d7e7192bfc..62b8dc0656 100644 --- a/src/foundation/src/Console/EnvironmentDecryptCommand.php +++ b/src/foundation/src/Console/EnvironmentDecryptCommand.php @@ -7,12 +7,12 @@ use Dotenv\Parser\Lines; use Exception; use Hypervel\Console\Command; +use Hypervel\Coroutine\Coroutine; use Hypervel\Encryption\Encrypter; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Env; use Hypervel\Support\Str; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Attribute\AsCommand; use function Hypervel\Prompts\password; @@ -44,6 +44,7 @@ public function __construct( */ public function handle(): void { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the key decrypts the environment file, so configuration cannot hold it) $key = $this->option('key') ?: Env::get('HYPERVEL_ENV_ENCRYPTION_KEY'); if (! $key && $this->input->isInteractive()) { @@ -102,9 +103,11 @@ public function handle(): void } $this->files->replace($outputFile, $decrypted, $mode); - } catch (CanceledException $exception) { - throw $exception; } catch (Exception $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + $this->fail($e->getMessage()); } diff --git a/src/foundation/src/Console/EnvironmentEncryptCommand.php b/src/foundation/src/Console/EnvironmentEncryptCommand.php index 3fd249d46a..4404b15312 100644 --- a/src/foundation/src/Console/EnvironmentEncryptCommand.php +++ b/src/foundation/src/Console/EnvironmentEncryptCommand.php @@ -7,11 +7,11 @@ use Dotenv\Parser\Lines; use Exception; use Hypervel\Console\Command; +use Hypervel\Coroutine\Coroutine; use Hypervel\Encryption\Encrypter; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Str; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Attribute\AsCommand; use function Hypervel\Prompts\password; @@ -123,9 +123,11 @@ public function handle(): void $this->files->replace($encryptedFile, $encrypted, $mode); } - } catch (CanceledException $exception) { - throw $exception; } catch (Exception $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + $this->fail($e->getMessage()); } diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index 9d13bc7bc6..57a445dc26 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -28,7 +28,6 @@ use Hypervel\Support\Str; use ReflectionClass; use SplFileInfo; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\ConsoleEvents; @@ -130,6 +129,7 @@ public function __construct( * Re-route the Symfony command events to their Hypervel counterparts. * * @internal + * @return $this */ public function rerouteSymfonyCommandEvents(): static { @@ -183,21 +183,23 @@ public function handle(InputInterface $input, ?OutputInterface $output = null): $this->bootstrap(); return $this->getArtisan()->run($input, $output); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + // Keep the original in flight while it is handled, so a failure in // reporting or rendering carries it as that failure's previous. The // return suppresses it once the command status is produced. try { - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ throw $e; } finally { $this->reportException($e); $this->renderException($output, $e); - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ return 1; } } @@ -354,6 +356,8 @@ protected function commands(): void * Register a Closure based command with the application. * * Boot or tests only. Commands remain registered for the kernel's lifetime. + * + * @param-closure-this ClosureCommand $callback */ public function command(string $signature, Closure $callback): ClosureCommand { @@ -590,6 +594,8 @@ public function setArtisan(?ApplicationContract $artisan): void /** * Set the Artisan commands provided by the application. + * + * @return $this */ public function addCommands(array $commands): static { @@ -604,6 +610,8 @@ public function addCommands(array $commands): static /** * Set the paths that should have their Artisan commands automatically discovered. + * + * @return $this */ public function addCommandPaths(array $paths): static { @@ -614,6 +622,8 @@ public function addCommandPaths(array $paths): static /** * Set the paths that should have their Artisan "routes" automatically discovered. + * + * @return $this */ public function addCommandRoutePaths(array $paths): static { diff --git a/src/foundation/src/Console/ProviderMakeCommand.php b/src/foundation/src/Console/ProviderMakeCommand.php index d3bd40d85a..f720c700e5 100644 --- a/src/foundation/src/Console/ProviderMakeCommand.php +++ b/src/foundation/src/Console/ProviderMakeCommand.php @@ -44,7 +44,7 @@ public function handle(): bool|int ServiceProvider::addProviderToBootstrapFile( $this->qualifyClass($this->getNameInput()), - $this->hypervel->getBootstrapProvidersPath(), // @phpstan-ignore method.notFound + $this->hypervel->getBootstrapProvidersPath(), // @phpstan-ignore method.notFound (Application method outside the contract) ); return $result; diff --git a/src/foundation/src/Console/RouteCacheCommand.php b/src/foundation/src/Console/RouteCacheCommand.php index 07b5dcecfc..7a809c2d62 100644 --- a/src/foundation/src/Console/RouteCacheCommand.php +++ b/src/foundation/src/Console/RouteCacheCommand.php @@ -5,10 +5,11 @@ namespace Hypervel\Foundation\Console; use Hypervel\Console\Command; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\PackageManifest; use Hypervel\Routing\RouteCollection; use LogicException; -use ReflectionClass; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\ArgvInput; @@ -170,7 +171,7 @@ protected function getFreshCompiledRoutesFromSubprocess(): array throw $throwable; } finally { - $cleanupException = null; + $failure = $exception; foreach ([$dumpPath, $buildPath] as $path) { if (! is_string($path)) { @@ -182,12 +183,12 @@ protected function getFreshCompiledRoutesFromSubprocess(): array throw new RuntimeException("Unable to delete the temporary route cache file [{$path}]."); } } catch (Throwable $throwable) { - $cleanupException ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } } - if ($exception === null && $cleanupException !== null) { - throw $cleanupException; + if ($failure !== $exception) { + throw $failure; } } } @@ -207,20 +208,12 @@ protected function buildRouteCacheFile(array $compiled): string */ protected function resolveSubprocessAutoloadPath(): string { - $componentRoot = dirname((new ReflectionClass(Filesystem::class))->getFileName(), 4); + $autoloadPath = $this->hypervel->make(PackageManifest::class)->vendorPath . '/autoload.php'; - $candidates = array_unique([ - $this->hypervel->basePath('vendor/autoload.php'), - $componentRoot . '/vendor/autoload.php', - dirname($componentRoot, 2) . '/autoload.php', - ]); - - foreach ($candidates as $candidate) { - if (is_file($candidate)) { - return $candidate; - } + if (! is_file($autoloadPath)) { + throw new LogicException('Unable to locate the Composer autoloader for the route cache subprocess.'); } - throw new LogicException('Unable to locate the Composer autoloader for the route cache subprocess.'); + return $autoloadPath; } } diff --git a/src/foundation/src/Console/UpCommand.php b/src/foundation/src/Console/UpCommand.php index b4d383553a..27e55e76fc 100644 --- a/src/foundation/src/Console/UpCommand.php +++ b/src/foundation/src/Console/UpCommand.php @@ -6,6 +6,7 @@ use Hypervel\Console\Command; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Console\Concerns\ReloadsWorkers; use Hypervel\Foundation\Events\MaintenanceModeDisabled; use Symfony\Component\Console\Attribute\AsCommand; @@ -59,7 +60,7 @@ public function handle(): int try { $this->reloadWorkers(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/foundation/src/Console/ViewCacheCommand.php b/src/foundation/src/Console/ViewCacheCommand.php index 0465f38c06..57b1c16d00 100644 --- a/src/foundation/src/Console/ViewCacheCommand.php +++ b/src/foundation/src/Console/ViewCacheCommand.php @@ -6,6 +6,8 @@ use Hypervel\Console\Command; use Hypervel\Support\Collection; +use Hypervel\View\Engines\CompilerEngine; +use Hypervel\View\FileViewFinder; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Finder\Finder; @@ -43,7 +45,10 @@ public function handle(): void */ protected function compileViews(Collection $views): void { - $compiler = $this->hypervel->make('view')->getEngineResolver()->resolve('blade')->getCompiler(); + /** @var CompilerEngine $engine */ + $engine = $this->hypervel->make('view')->getEngineResolver()->resolve('blade'); + + $compiler = $engine->getCompiler(); $views->map(function (SplFileInfo $file) use ($compiler) { $this->components->task(' ' . $file->getRelativePathname(), null, OutputInterface::VERBOSITY_VERY_VERBOSE); @@ -81,6 +86,7 @@ protected function bladeFilesIn(array $paths): Collection */ protected function paths(): Collection { + /** @var FileViewFinder $finder */ $finder = $this->hypervel->make('view')->getFinder(); $paths = (new Collection($finder->getPaths()))->merge( diff --git a/src/foundation/src/Console/ViewClearCommand.php b/src/foundation/src/Console/ViewClearCommand.php index 640cbca507..9c1afc2428 100644 --- a/src/foundation/src/Console/ViewClearCommand.php +++ b/src/foundation/src/Console/ViewClearCommand.php @@ -5,6 +5,7 @@ namespace Hypervel\Foundation\Console; use Hypervel\Console\Command; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\View\Component; use Hypervel\View\Engines\CompilerEngine; @@ -65,7 +66,7 @@ public function handle(): void throw new RuntimeException("Unable to delete the compiled view [{$view}]."); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/foundation/src/Events/PublishingStubs.php b/src/foundation/src/Events/PublishingStubs.php index 42c0396dbf..4785bab095 100644 --- a/src/foundation/src/Events/PublishingStubs.php +++ b/src/foundation/src/Events/PublishingStubs.php @@ -23,6 +23,8 @@ public function __construct(array $stubs) /** * Add a new stub to be published. + * + * @return $this */ public function add(string $path, string $name): static { diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index 1289039c02..360e6332fe 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -252,6 +252,8 @@ public function reportable(callable $reportUsing): ReportableHandler * * Boot-only. The callback persists on the shared handler and affects exception * rendering for every subsequent request in the worker. + * + * @return $this */ public function renderable(callable $renderUsing): static { @@ -270,6 +272,8 @@ public function renderable(callable $renderUsing): static * Boot-only. The mapping persists on the shared handler and affects exception * reporting and rendering for every subsequent request and job in the worker. * + * @return $this + * * @throws InvalidArgumentException */ public function map(callable|string $from, Closure|string|null $to = null): static @@ -298,6 +302,8 @@ public function map(callable|string $from, Closure|string|null $to = null): stat * * Boot-only. The exception types persist on the shared handler and affect * exception reporting for every subsequent request and job in the worker. + * + * @return $this */ public function dontReport(array|string $exceptions): static { @@ -311,6 +317,7 @@ public function dontReport(array|string $exceptions): static * reporting for every subsequent request and job in the worker. * * @param (callable(Throwable): bool) $dontReportWhen + * @return $this */ public function dontReportWhen(callable $dontReportWhen): static { @@ -328,6 +335,8 @@ public function dontReportWhen(callable $dontReportWhen): static * * Boot-only. The exception types persist on the shared handler and affect * exception reporting for every subsequent request and job in the worker. + * + * @return $this */ public function ignore(array|string $exceptions): static { @@ -343,6 +352,8 @@ public function ignore(array|string $exceptions): static * * Boot-only. The exception types persist on the shared handler and affect * every subsequently failed job in the worker. + * + * @return $this */ public function dontRetry(array|string $exceptions): static { @@ -362,6 +373,7 @@ public function dontRetry(array|string $exceptions): static * @template TException of Throwable * * @param callable(TException): bool $dontRetryWhen + * @return $this */ public function dontRetryWhen(callable $dontRetryWhen): static { @@ -436,6 +448,8 @@ public function shouldStopRetries(Throwable $e): bool * * Boot-only. The attributes persist on the shared handler and are omitted * from every subsequent validation redirect in the worker. + * + * @return $this */ public function dontFlash(array|string $attributes): static { @@ -454,6 +468,7 @@ public function dontFlash(array|string $attributes): static * * @param class-string $type * @param LogLevel::* $level + * @return $this */ public function level(string $type, string $level): static { @@ -473,6 +488,7 @@ public function level(string $type, string $level): static public function report(Throwable $e, array $context = [], ?string $level = null): void { // Cancellation must not reach user-defined exception mappers. + // @phpstan-ignore hypervel.cancellation.exactType (a direct cancellation is never reported; a failure carrying it is reported once below) if ($e instanceof CanceledException) { return; } @@ -640,6 +656,7 @@ public function shouldReport(Throwable $e): bool */ protected function shouldntReport(Throwable $e): bool { + // @phpstan-ignore hypervel.cancellation.exactType (a direct cancellation is never reported; a failure carrying it is reported once) if ($e instanceof CanceledException) { return true; } @@ -709,6 +726,8 @@ protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null * * Boot-only. The callback persists on the shared handler and affects exception * reporting for every subsequent request and job in the worker. + * + * @return $this */ public function throttleUsing(callable $throttleUsing): static { @@ -726,6 +745,8 @@ public function throttleUsing(callable $throttleUsing): static * * Boot-only. The exception lists persist on the shared handler and affect * exception reporting for every subsequent request and job in the worker. + * + * @return $this */ public function stopIgnoring(array|string $exceptions): static { @@ -820,6 +841,7 @@ protected function context(): array * subsequently logged exception in the worker. * * @param Closure(Throwable, array): array $contextCallback + * @return $this */ public function buildContextUsing(Closure $contextCallback): static { @@ -896,6 +918,7 @@ protected function finalizeRenderedResponse(Request $request, SymfonyResponse $r * Callback return values are ignored. * * @param callable(SymfonyResponse, Throwable, Request): mixed $callback + * @return $this */ public function beforeResponding(callable $callback): static { @@ -930,6 +953,8 @@ protected function afterResponseCallbacks(): array * * Boot-only. The callback replaces the shared handler's response callback for * every subsequent request in the worker. + * + * @return $this */ public function respondUsing(callable $callback): static { @@ -1086,6 +1111,7 @@ protected function shouldReturnJson(Request $request, Throwable $e): bool * subsequent request in the worker. * * @param callable(Request $request, Throwable): bool $callback + * @return $this */ public function shouldRenderJsonWhen(callable $callback): static { @@ -1315,6 +1341,8 @@ public function renderForConsole(OutputInterface $output, Throwable $e): void * * Boot-only. The setting persists on the shared handler and applies to every * subsequent request and job in the worker. + * + * @return $this */ public function dontReportDuplicates(): static { diff --git a/src/foundation/src/Exceptions/Renderer/Exception.php b/src/foundation/src/Exceptions/Renderer/Exception.php index 0d76a7a673..58d563a67f 100644 --- a/src/foundation/src/Exceptions/Renderer/Exception.php +++ b/src/foundation/src/Exceptions/Renderer/Exception.php @@ -5,11 +5,11 @@ namespace Hypervel\Foundation\Exceptions\Renderer; use Closure; -use Composer\Autoload\ClassLoader; use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Bootstrap\HandleExceptions; use Hypervel\Http\Request; use Hypervel\Support\Collection; +use Hypervel\Support\Composer; use Hypervel\Support\Str; use Symfony\Component\ErrorHandler\Exception\FlattenException; @@ -91,7 +91,7 @@ public function frames(): Collection return once(function () { $classMap = array_map(function ($path) { return (string) realpath($path); - }, array_values(ClassLoader::getRegisteredLoaders())[0]->getClassMap()); + }, Composer::getLoader()->getClassMap()); $trace = $this->exception->getTrace(); diff --git a/src/foundation/src/Http/FormRequest.php b/src/foundation/src/Http/FormRequest.php index 5fdec02d3e..5425d3e04a 100644 --- a/src/foundation/src/Http/FormRequest.php +++ b/src/foundation/src/Http/FormRequest.php @@ -184,6 +184,7 @@ protected function configureFromAttributes(): void } } + // @phpstan-ignore hypervel.staticStateMutation (per-class attribute metadata, the same for every request and bounded by the form request classes) static::$attributeConfiguration[$class] = $config; } @@ -332,7 +333,7 @@ protected function failedAuthorization(): void * Get a validated input container for the validated input. * * @param null|array $keys - * @return ($keys is array ? array : ValidatedInput) + * @return ($keys is array ? array : ValidatedInput) * * @throws ValidationException */ @@ -347,6 +348,8 @@ public function safe(?array $keys = null): array|ValidatedInput /** * Get the validated data from the request. + * + * @return ($key is null ? array : mixed) */ public function validated(array|int|string|null $key = null, mixed $default = null): mixed { @@ -415,6 +418,8 @@ public static function failOnUnknownFields(bool $value = true): void /** * Set the Validator instance. + * + * @return $this */ public function setValidator(Validator $validator): static { @@ -425,6 +430,8 @@ public function setValidator(Validator $validator): static /** * Set the Redirector instance. + * + * @return $this */ public function setRedirector(Redirector $redirector): static { @@ -435,6 +442,8 @@ public function setRedirector(Redirector $redirector): static /** * Set the container implementation. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/foundation/src/Http/HtmlDumper.php b/src/foundation/src/Http/HtmlDumper.php index e4dfdc9e9a..597d817f11 100644 --- a/src/foundation/src/Http/HtmlDumper.php +++ b/src/foundation/src/Http/HtmlDumper.php @@ -46,7 +46,7 @@ public function __construct( */ public static function register(string $basePath, string $compiledViewPath): void { - $cloner = tap(new VarCloner)->addCasters(ReflectionCaster::UNSET_CLOSURE_FILE_INFO); // @phpstan-ignore method.notFound (tap proxy __call) + $cloner = tap(new VarCloner)->addCasters(ReflectionCaster::UNSET_CLOSURE_FILE_INFO); $dumper = new static($basePath, $compiledViewPath); diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php index 99c6c22991..217de21581 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -21,7 +21,6 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\InteractsWithTime; use InvalidArgumentException; -use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -177,19 +176,21 @@ public function handle(Request $request): Response } return $response; - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + // Keep the original in flight while it is handled, so a failure in // reporting or rendering carries it as that failure's previous. The // return suppresses it once a response exists. try { - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ throw $e; } finally { $this->reportException($e); - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ return $this->renderException($request, $e); } } diff --git a/src/foundation/src/Http/WebSocketKernel.php b/src/foundation/src/Http/WebSocketKernel.php index bcd22a999e..c92f7538b9 100644 --- a/src/foundation/src/Http/WebSocketKernel.php +++ b/src/foundation/src/Http/WebSocketKernel.php @@ -24,14 +24,14 @@ protected function handleException(Throwable $throwable): Response // reporting or rendering carries it as that failure's previous. The // return suppresses it once a response exists. try { - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ throw $throwable; } finally { $handler = $this->container->make(ExceptionHandlerContract::class); $handler->report($throwable); - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ return $handler->render(RequestContext::get(), $throwable); } } diff --git a/src/foundation/src/MaintenanceModeManager.php b/src/foundation/src/MaintenanceModeManager.php index 62d46443a3..2b4001905b 100644 --- a/src/foundation/src/MaintenanceModeManager.php +++ b/src/foundation/src/MaintenanceModeManager.php @@ -5,8 +5,12 @@ namespace Hypervel\Foundation; use Hypervel\Contracts\Container\BindingResolutionException; +use Hypervel\Contracts\Foundation\MaintenanceMode; use Hypervel\Support\Manager; +/** + * @mixin MaintenanceMode + */ class MaintenanceModeManager extends Manager { /** diff --git a/src/foundation/src/PHPStan/HelperFunctionReturnTypeExtension.php b/src/foundation/src/PHPStan/HelperFunctionReturnTypeExtension.php deleted file mode 100644 index 6cc023ae6a..0000000000 --- a/src/foundation/src/PHPStan/HelperFunctionReturnTypeExtension.php +++ /dev/null @@ -1,52 +0,0 @@ -getName(), ['response', 'validator'], true); - } - - /** - * Infer the helper result without treating an explicit null as an omitted argument. - */ - public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type - { - [$factory, $result] = $functionReflection->getName() === 'response' - ? [ResponseFactory::class, Response::class] - : [ValidationFactory::class, Validator::class]; - - $arguments = $functionCall->getArgs(); - - if ($arguments === []) { - return new ObjectType($factory); - } - - foreach ($arguments as $argument) { - if (! $argument->unpack) { - return new ObjectType($result); - } - } - - return TypeCombinator::union(new ObjectType($factory), new ObjectType($result)); - } -} diff --git a/src/foundation/src/PackageManifest.php b/src/foundation/src/PackageManifest.php index 623e31c6d6..9c06f04692 100644 --- a/src/foundation/src/PackageManifest.php +++ b/src/foundation/src/PackageManifest.php @@ -7,7 +7,7 @@ use Exception; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Collection; -use Hypervel\Support\Env; +use Hypervel\Support\Composer; use Hypervel\Support\Json; use RuntimeException; use UnexpectedValueException; @@ -66,7 +66,7 @@ public function __construct(Filesystem $files, string $basePath, ?string $manife $this->files = $files; $this->basePath = $basePath; $this->manifestPath = $manifestPath; - $this->vendorPath = Env::get('COMPOSER_VENDOR_DIR') ?: $basePath . '/vendor'; + $this->vendorPath = Composer::vendorPath($basePath); } /** @@ -164,9 +164,17 @@ protected function getManifest(): array $this->build(); } - $this->rawManifest = is_file($this->manifestPath) + $manifest = is_file($this->manifestPath) ? $this->files->getRequire($this->manifestPath) : []; + + $this->rawManifest = $this->prepareManifest($manifest); + + // A cached manifest can predate the current discovery settings. Keep the + // file in step for other processes that read it. + if ($this->rawManifest !== $manifest) { + $this->write($this->rawManifest); + } } $ignore = static::$packagesToIgnore; @@ -266,11 +274,11 @@ public static function discoverInstalledPackages(Filesystem $files, string $vend */ public function build(): void { - $manifest = static::discoverInstalledPackages( + $manifest = $this->prepareManifest(static::discoverInstalledPackages( $this->files, $this->vendorPath, $this->packagesToIgnore() - ); + )); $this->write($manifest); @@ -278,6 +286,17 @@ public function build(): void $this->rawManifest = $manifest; } + /** + * Prepare a built or cached manifest before it is stored or used. + * + * @param array> $manifest + * @return array> + */ + protected function prepareManifest(array $manifest): array + { + return $manifest; + } + /** * Format the given package name. */ diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php index c9dc179852..f101cb017e 100644 --- a/src/foundation/src/Providers/FoundationServiceProvider.php +++ b/src/foundation/src/Providers/FoundationServiceProvider.php @@ -302,8 +302,10 @@ protected function registerDeferHandler(): void // Deferred jobs use coroutine-exit callbacks, not this collection, so their // JobAttempted events can drain it without recursively running the same job. + // A canceled attempt skips deferred work, as every other canceled boundary does. $events->listen(function (JobAttempted $event) { if ($event->connectionName === 'sync' + || $event->canceled || ! BaseContainer::getInstance()->resolvedScoped(DeferredCallbackCollection::class) ) { return; @@ -338,7 +340,7 @@ public function registerRequestValidation(): void Request::macro('validateWithBag', function (string $errorBag, array $rules, ...$params) { try { return $this->validate($rules, ...$params); - } catch (ValidationException $e) { // @phpstan-ignore catch.neverThrown ($this->validate() is a macro that throws ValidationException) + } catch (ValidationException $e) { $e->errorBag = $errorBag; throw $e; diff --git a/src/foundation/src/Routing/PrecognitionControllerDispatcher.php b/src/foundation/src/Routing/PrecognitionControllerDispatcher.php index 73dc1ec939..c1e07f2ebb 100644 --- a/src/foundation/src/Routing/PrecognitionControllerDispatcher.php +++ b/src/foundation/src/Routing/PrecognitionControllerDispatcher.php @@ -25,6 +25,8 @@ public function dispatch(Route $route, mixed $controller, string $method): mixed /** * Ensure that the given method exists on the controller. * + * @return $this + * * @throws RuntimeException */ protected function ensureMethodExists(object $controller, string $method): static diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php index ea5e13e5b2..7f1d83b7c4 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php @@ -10,6 +10,7 @@ use Algolia\AlgoliaSearch\Http\HttpClientInterface; use Algolia\AlgoliaSearch\Model\Search\GetTaskResponse; use GuzzleHttp\Client as GuzzleClient; +use Hypervel\Support\Env; use RuntimeException; use Throwable; @@ -45,8 +46,10 @@ trait InteractsWithAlgolia */ protected function setUpInteractsWithAlgolia(): void { - $appId = env('ALGOLIA_APP_ID'); - $secret = env('ALGOLIA_SECRET'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + $appId = Env::get('ALGOLIA_APP_ID'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + $secret = Env::get('ALGOLIA_SECRET'); if ($appId === null || $secret === null || $appId === '' || $secret === '') { $this->markTestSkipped( @@ -109,7 +112,8 @@ protected function createAlgoliaHttpClient(): HttpClientInterface protected function computeAlgoliaTestPrefix(): void { $base = 'test_'; - $token = env('TEST_TOKEN', ''); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN', ''); $this->algoliaTestPrefix = $token !== '' ? "{$base}{$token}_" : $base; } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php b/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php index 97fd24cfe2..aefa800481 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php @@ -10,6 +10,8 @@ trait InteractsWithAuthentication { /** * Set the currently logged in user for the application. + * + * @return $this */ public function actingAs(UserContract $user, ?string $guard = null): static { @@ -18,6 +20,8 @@ public function actingAs(UserContract $user, ?string $guard = null): static /** * Clear the currently logged in user for the application. + * + * @return $this */ public function actingAsGuest(?string $guard = null): static { @@ -32,6 +36,8 @@ public function actingAsGuest(?string $guard = null): static /** * Set the currently logged in user for the application. + * + * @return $this */ public function be(UserContract $user, ?string $guard = null): static { @@ -50,6 +56,8 @@ public function be(UserContract $user, ?string $guard = null): static /** * Assert that the user is authenticated. + * + * @return $this */ public function assertAuthenticated(?string $guard = null): static { @@ -60,6 +68,8 @@ public function assertAuthenticated(?string $guard = null): static /** * Assert that the user is not authenticated. + * + * @return $this */ public function assertGuest(?string $guard = null): static { @@ -78,6 +88,8 @@ protected function isAuthenticated(?string $guard = null): bool /** * Assert that the user is authenticated as the given user. + * + * @return $this */ public function assertAuthenticatedAs(UserContract $user, ?string $guard = null): static { @@ -102,6 +114,8 @@ public function assertAuthenticatedAs(UserContract $user, ?string $guard = null) /** * Assert that the given credentials are valid. + * + * @return $this */ public function assertCredentials(array $credentials, ?string $guard = null): static { @@ -115,6 +129,8 @@ public function assertCredentials(array $credentials, ?string $guard = null): st /** * Assert that the given credentials are invalid. + * + * @return $this */ public function assertInvalidCredentials(array $credentials, ?string $guard = null): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php index 91828273f2..83a6eb537f 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php @@ -82,6 +82,8 @@ public function realArtisan(string $command, array $parameters = []): int * * When using this with traits like DatabaseMigrations, call this in setUp() * before parent::setUp() to ensure mock output is never bound. + * + * @return $this */ protected function withoutMockingConsoleOutput(): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php index ce96bed049..4411204f2c 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php @@ -249,6 +249,8 @@ protected function withVite(): static /** * Execute deferred callbacks immediately. + * + * @return $this */ protected function withoutDefer(): static { @@ -273,6 +275,8 @@ public function offsetSet(mixed $offset, mixed $value): void /** * Restore deferred callbacks. + * + * @return $this */ protected function withDefer(): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php index f7c880ce14..b517a42cdb 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php @@ -26,6 +26,7 @@ trait InteractsWithDatabase * * @param class-string|iterable|Model|string>|Model|string $table * @param array|list> $data + * @return $this */ protected function assertDatabaseHas(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null): static { @@ -65,6 +66,7 @@ protected function assertDatabaseHas(iterable|Model|string $table, array $data = * * @param class-string|iterable|Model|string>|Model|string $table * @param array|list> $data + * @return $this */ protected function assertDatabaseMissing(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null): static { @@ -104,6 +106,7 @@ protected function assertDatabaseMissing(iterable|Model|string $table, array $da * Assert the count of table entries. * * @param class-string|iterable|string, int>|Model|string $table + * @return $this */ protected function assertDatabaseCount(iterable|Model|string $table, ?int $count = null, UnitEnum|string|null $connection = null): static { @@ -127,6 +130,7 @@ protected function assertDatabaseCount(iterable|Model|string $table, ?int $count * Assert that the given table or tables has no entries. * * @param class-string|iterable|Model|string>|Model|string $table + * @return $this */ protected function assertDatabaseEmpty(iterable|Model|string $table, UnitEnum|string|null $connection = null): static { @@ -151,6 +155,7 @@ protected function assertDatabaseEmpty(iterable|Model|string $table, UnitEnum|st * * @param class-string|iterable|Model|string>|Model|string $table * @param array|list> $data + * @return $this */ protected function assertSoftDeleted(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null, ?string $deletedAtColumn = 'deleted_at'): static { @@ -197,6 +202,7 @@ protected function assertSoftDeleted(iterable|Model|string $table, array $data = * * @param class-string|iterable|Model|string>|Model|string $table * @param array|list> $data + * @return $this */ protected function assertNotSoftDeleted(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null, ?string $deletedAtColumn = 'deleted_at'): static { @@ -242,6 +248,7 @@ protected function assertNotSoftDeleted(iterable|Model|string $table, array $dat * Assert the given model exists in the database. * * @param class-string|iterable|Model|string>|Model|string $model + * @return $this */ protected function assertModelExists(iterable|Model|string $model): static { @@ -252,6 +259,7 @@ protected function assertModelExists(iterable|Model|string $model): static * Assert the given model does not exist in the database. * * @param class-string|iterable|Model|string>|Model|string $model + * @return $this */ protected function assertModelMissing(iterable|Model|string $model): static { @@ -260,6 +268,8 @@ protected function assertModelMissing(iterable|Model|string $model): static /** * Specify the number of database queries that should occur throughout the test. + * + * @return $this */ public function expectsDatabaseQueryCount(int $expected, UnitEnum|string|null $connection = null): static { @@ -331,6 +341,8 @@ protected function getConnection(UnitEnum|string|null $connection = null, Model| $connection = $database->getDefaultConnection(); } + // ConnectionFactory makes every managed connection, including extended drivers, a Connection. + /** @var Connection */ return $database->connection($connection); } @@ -386,6 +398,7 @@ protected function newModelFor(Model|string|null $table): ?Model * Seed a given database connection. * * @param class-string|list|string $class + * @return $this */ public function seed(array|string $class = 'Database\Seeders\DatabaseSeeder'): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDeprecationHandling.php b/src/foundation/src/Testing/Concerns/InteractsWithDeprecationHandling.php index 4a8ca3e0fa..5e2ad8181e 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDeprecationHandling.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDeprecationHandling.php @@ -15,6 +15,8 @@ trait InteractsWithDeprecationHandling /** * Restore deprecation handling. + * + * @return $this */ protected function withDeprecationHandling(): static { @@ -28,6 +30,8 @@ protected function withDeprecationHandling(): static /** * Disable deprecation handling for the test. * + * @return $this + * * @throws ErrorException */ protected function withoutDeprecationHandling(): static diff --git a/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php b/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php index f6853b8e33..5b630f6ac1 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php @@ -29,6 +29,8 @@ trait InteractsWithExceptionHandling /** * Restore exception handling. + * + * @return $this */ protected function withExceptionHandling(): static { @@ -47,6 +49,7 @@ protected function withExceptionHandling(): static * Only handle the given exceptions via the exception handler. * * @param list> $exceptions + * @return $this */ protected function handleExceptions(array $exceptions): static { @@ -55,6 +58,8 @@ protected function handleExceptions(array $exceptions): static /** * Only handle validation exceptions via the exception handler. + * + * @return $this */ protected function handleValidationExceptions(): static { @@ -65,6 +70,7 @@ protected function handleValidationExceptions(): static * Disable exception handling for the test. * * @param list> $except + * @return $this */ protected function withoutExceptionHandling(array $except = []): static { @@ -178,6 +184,7 @@ public function afterResponse(callable $callback): void * Assert that the given callback throws an exception with the given message when invoked. * * @param class-string|Closure(Throwable): bool $expectedClass + * @return $this */ protected function assertThrows(Closure $test, string|Closure $expectedClass = Throwable::class, ?string $expectedMessage = null): static { @@ -212,6 +219,8 @@ protected function assertThrows(Closure $test, string|Closure $expectedClass = T /** * Assert that the given callback does not throw an exception. + * + * @return $this */ protected function assertDoesntThrow(Closure $test): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php b/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php index b8be65e128..327eecd906 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Support\Env; use Meilisearch\Client as MeilisearchClient; use RuntimeException; @@ -34,7 +35,8 @@ trait InteractsWithMeilisearch */ protected function setUpInteractsWithMeilisearch(): void { - if (env('MEILISEARCH_HOST') === null) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + if (Env::get('MEILISEARCH_HOST') === null) { $this->markTestSkipped( 'Set MEILISEARCH_HOST to run Meilisearch integration tests for ' . static::class ); @@ -74,7 +76,8 @@ protected function initializeMeilisearchClient(): void { $this->meilisearch = new MeilisearchClient( $this->getMeilisearchHost(), - env('MEILISEARCH_KEY', '') + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + Env::get('MEILISEARCH_KEY', '') ); } @@ -84,7 +87,8 @@ protected function initializeMeilisearchClient(): void protected function computeMeilisearchTestPrefix(): void { $base = 'test_'; - $token = env('TEST_TOKEN', ''); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN', ''); $this->meilisearchTestPrefix = $token !== '' ? "{$base}{$token}_" : $base; } @@ -96,8 +100,10 @@ protected function computeMeilisearchTestPrefix(): void */ protected function getMeilisearchHost(): string { - $host = env('MEILISEARCH_HOST', '127.0.0.1'); - $port = env('MEILISEARCH_PORT', '7700'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + $host = Env::get('MEILISEARCH_HOST', '127.0.0.1'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + $port = Env::get('MEILISEARCH_PORT', '7700'); return "http://{$host}:{$port}"; } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithMercure.php b/src/foundation/src/Testing/Concerns/InteractsWithMercure.php index 139cd03adc..7b806d209d 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithMercure.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithMercure.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Support\Env; use Hypervel\Testing\ParallelTesting; /** @@ -18,7 +19,8 @@ trait InteractsWithMercure */ protected function setUpInteractsWithMercure(): void { - if (env('MERCURE_URL') === null) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + if (Env::get('MERCURE_URL') === null) { $this->markTestSkipped('Set MERCURE_URL to run Mercure integration tests for ' . static::class); } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php index ed714032b3..e907c2e00f 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php @@ -8,6 +8,7 @@ use Hypervel\Database\ConfigurationUrlParser; use Hypervel\Database\QueryException; use Hypervel\Database\SQLiteDatabase; +use Hypervel\Support\Env; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; use InvalidArgumentException; @@ -45,7 +46,8 @@ protected function configureParallelDatabaseName($app): void return; } - $token = env('TEST_TOKEN'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN'); if ($token === null) { return; @@ -98,7 +100,8 @@ protected function ensureParallelDatabaseExists(): void return; } - $token = env('TEST_TOKEN'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN'); if ($token === null) { return; diff --git a/src/foundation/src/Testing/Concerns/InteractsWithServer.php b/src/foundation/src/Testing/Concerns/InteractsWithServer.php index 005fc6b814..efb1320abe 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithServer.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithServer.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Support\Env; use RuntimeException; use Throwable; @@ -28,7 +29,8 @@ trait InteractsWithServer */ protected function setUpInteractsWithServer(): void { - if (env('TEST_SERVER_HOST') === null) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + if (Env::get('TEST_SERVER_HOST') === null) { $this->markTestSkipped( 'Set TEST_SERVER_HOST to run server integration tests for ' . static::class ); @@ -65,7 +67,8 @@ protected function canConnectToServer(): bool */ protected function getServerHost(): string { - return (string) env('TEST_SERVER_HOST'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + return (string) Env::get('TEST_SERVER_HOST'); } /** diff --git a/src/foundation/src/Testing/Concerns/InteractsWithSession.php b/src/foundation/src/Testing/Concerns/InteractsWithSession.php index 6f15d4f185..605731d0f7 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithSession.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithSession.php @@ -8,6 +8,8 @@ trait InteractsWithSession { /** * Set the session to the given array. + * + * @return $this */ public function withSession(array $data): static { @@ -18,6 +20,8 @@ public function withSession(array $data): static /** * Set the session to the given array. + * + * @return $this */ public function session(array $data): static { @@ -33,6 +37,8 @@ public function session(array $data): static /** * Start the session for the application. + * + * @return $this */ protected function startSession(): static { @@ -47,6 +53,8 @@ protected function startSession(): static /** * Flush all of the current session data. + * + * @return $this */ public function flushSession(): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php index 69885faea4..61939bb0f5 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php @@ -134,7 +134,7 @@ protected function tearDownTheTestEnvironment(): void fn () => DatabaseConnectionResolver::flushCachedConnections() ); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } try { @@ -146,19 +146,19 @@ protected function tearDownTheTestEnvironment(): void ); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } try { ParallelTesting::callTearDownTestCaseCallbacks($this); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } try { $app->flush(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -173,7 +173,7 @@ protected function tearDownTheTestEnvironment(): void try { $this->flushState(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { @@ -204,11 +204,11 @@ protected function setUpTraits(): array $this->setUpDatabaseTraits($uses); if (isset($uses[WithoutMiddleware::class])) { - $this->disableMiddlewareForAllTests(); // @phpstan-ignore method.notFound + $this->disableMiddlewareForAllTests(); // @phpstan-ignore method.notFound (provided by WithoutMiddleware, checked above) } if (isset($uses[WithoutEvents::class])) { - $this->disableEventsForAllTests(); // @phpstan-ignore method.notFound + $this->disableEventsForAllTests(); // @phpstan-ignore method.notFound (provided by WithoutEvents, checked above) } foreach ($uses as $trait) { @@ -259,19 +259,19 @@ protected function setUpDatabaseTraits(array $uses): void $this->forgetStaleMigrationState($uses); if (isset($uses[RefreshDatabase::class])) { - $this->refreshDatabase(); // @phpstan-ignore method.notFound + $this->refreshDatabase(); // @phpstan-ignore method.notFound (provided by RefreshDatabase, checked above) } if (isset($uses[DatabaseMigrations::class])) { - $this->runDatabaseMigrations(); // @phpstan-ignore method.notFound + $this->runDatabaseMigrations(); // @phpstan-ignore method.notFound (provided by DatabaseMigrations, checked above) } if (isset($uses[DatabaseTruncation::class])) { - $this->truncateDatabaseTables(); // @phpstan-ignore method.notFound + $this->truncateDatabaseTables(); // @phpstan-ignore method.notFound (provided by DatabaseTruncation, checked above) } if (isset($uses[DatabaseTransactions::class])) { - $this->beginDatabaseTransaction(); // @phpstan-ignore method.notFound + $this->beginDatabaseTransaction(); // @phpstan-ignore method.notFound (provided by DatabaseTransactions, checked above) } } @@ -285,8 +285,8 @@ protected function forgetStaleMigrationState(array $uses): void } if ( - (isset($uses[RefreshDatabase::class]) && $this->hasMissingInMemoryDatabaseForRefresh()) // @phpstan-ignore method.notFound - || (isset($uses[DatabaseTruncation::class]) && $this->hasMissingInMemoryDatabaseForTruncation()) // @phpstan-ignore method.notFound + (isset($uses[RefreshDatabase::class]) && $this->hasMissingInMemoryDatabaseForRefresh()) // @phpstan-ignore method.notFound (provided by RefreshDatabase, checked first) + || (isset($uses[DatabaseTruncation::class]) && $this->hasMissingInMemoryDatabaseForTruncation()) // @phpstan-ignore method.notFound (provided by DatabaseTruncation, checked first) ) { // A persistent database's migrated flag does not describe an uncached in-memory database. RefreshDatabaseState::$migrated = false; diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php b/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php index b3a77cc5af..d1187a6f51 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Support\Env; use Typesense\Client as TypesenseClient; /** @@ -33,7 +34,8 @@ trait InteractsWithTypesense */ protected function setUpInteractsWithTypesense(): void { - if (env('TYPESENSE_HOST') === null) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + if (Env::get('TYPESENSE_HOST') === null) { $this->markTestSkipped( 'Set TYPESENSE_HOST to run Typesense integration tests for ' . static::class ); @@ -80,12 +82,16 @@ protected function initializeTypesenseClient(): void protected function getTypesenseClientSettings(): array { return [ - 'api_key' => env('TYPESENSE_API_KEY', ''), + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + 'api_key' => Env::get('TYPESENSE_API_KEY', ''), 'nodes' => [ [ - 'host' => env('TYPESENSE_HOST', '127.0.0.1'), - 'port' => (string) env('TYPESENSE_PORT', '8108'), - 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + 'host' => Env::get('TYPESENSE_HOST', '127.0.0.1'), + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + 'port' => (string) Env::get('TYPESENSE_PORT', '8108'), + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (integration tests read service settings from the test process environment) + 'protocol' => Env::get('TYPESENSE_PROTOCOL', 'http'), ], ], 'connection_timeout_seconds' => 2, @@ -98,7 +104,8 @@ protected function getTypesenseClientSettings(): array protected function computeTypesenseTestPrefix(): void { $base = 'test_'; - $token = env('TEST_TOKEN', ''); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN', ''); $this->typesenseTestPrefix = $token !== '' ? "{$base}{$token}_" : $base; } diff --git a/src/foundation/src/Testing/Concerns/MakesHttpRequests.php b/src/foundation/src/Testing/Concerns/MakesHttpRequests.php index 94e057ce2e..ddfdf755d2 100644 --- a/src/foundation/src/Testing/Concerns/MakesHttpRequests.php +++ b/src/foundation/src/Testing/Concerns/MakesHttpRequests.php @@ -74,6 +74,8 @@ trait MakesHttpRequests /** * Define additional headers to be sent with the request. + * + * @return $this */ public function withHeaders(array $headers): static { @@ -84,6 +86,8 @@ public function withHeaders(array $headers): static /** * Add a header to be sent with the request. + * + * @return $this */ public function withHeader(string $name, string $value): static { @@ -94,6 +98,8 @@ public function withHeader(string $name, string $value): static /** * Remove a header from the request. + * + * @return $this */ public function withoutHeader(string $name): static { @@ -104,6 +110,8 @@ public function withoutHeader(string $name): static /** * Remove headers from the request. + * + * @return $this */ public function withoutHeaders(array $headers): static { @@ -116,6 +124,8 @@ public function withoutHeaders(array $headers): static /** * Add an authorization token for the request. + * + * @return $this */ public function withToken(#[SensitiveParameter] string $token, string $type = 'Bearer'): static { @@ -124,6 +134,8 @@ public function withToken(#[SensitiveParameter] string $token, string $type = 'B /** * Add a basic authentication header to the request with the given credentials. + * + * @return $this */ public function withBasicAuth(string $username, #[SensitiveParameter] string $password): static { @@ -132,6 +144,8 @@ public function withBasicAuth(string $username, #[SensitiveParameter] string $pa /** * Remove the authorization token from the request. + * + * @return $this */ public function withoutToken(): static { @@ -140,6 +154,8 @@ public function withoutToken(): static /** * Flush all the configured headers. + * + * @return $this */ public function flushHeaders(): static { @@ -150,6 +166,8 @@ public function flushHeaders(): static /** * Define a set of server variables to be sent with the requests. + * + * @return $this */ public function withServerVariables(array $server): static { @@ -162,6 +180,7 @@ public function withServerVariables(array $server): static * Disable middleware for the test. * * @param null|array|string $middleware + * @return $this */ public function withoutMiddleware($middleware = null): static { @@ -182,6 +201,7 @@ public function withoutMiddleware($middleware = null): static * Enable the given middleware for the test. * * @param null|array|string $middleware + * @return $this */ public function withMiddleware($middleware = null): static { @@ -200,6 +220,8 @@ public function withMiddleware($middleware = null): static /** * Define additional cookies to be sent with the request. + * + * @return $this */ public function withCookies(array $cookies): static { @@ -210,6 +232,8 @@ public function withCookies(array $cookies): static /** * Add a cookie to be sent with the request. + * + * @return $this */ public function withCookie(string $name, string $value): static { @@ -220,6 +244,8 @@ public function withCookie(string $name, string $value): static /** * Define additional cookies that will not be encrypted before sending with the request. + * + * @return $this */ public function withUnencryptedCookies(array $cookies): static { @@ -230,6 +256,8 @@ public function withUnencryptedCookies(array $cookies): static /** * Add a cookie that will not be encrypted before sending with the request. + * + * @return $this */ public function withUnencryptedCookie(string $name, string $value): static { @@ -240,6 +268,8 @@ public function withUnencryptedCookie(string $name, string $value): static /** * Automatically follow any redirects returned from the response. + * + * @return $this */ public function followingRedirects(): static { @@ -250,6 +280,8 @@ public function followingRedirects(): static /** * Include cookies and authorization headers for JSON requests. + * + * @return $this */ public function withCredentials(): static { @@ -260,6 +292,8 @@ public function withCredentials(): static /** * Disable automatic encryption of cookie values. + * + * @return $this */ public function disableCookieEncryption(): static { @@ -270,6 +304,8 @@ public function disableCookieEncryption(): static /** * Set the referer header and previous URL session value in order to simulate a previous request. + * + * @return $this */ public function from(string $url): static { @@ -280,6 +316,8 @@ public function from(string $url): static /** * Set the referer header and previous URL session value from a given route in order to simulate a previous request. + * + * @return $this */ public function fromRoute(BackedEnum|string $name, mixed $parameters = []): static { @@ -288,6 +326,8 @@ public function fromRoute(BackedEnum|string $name, mixed $parameters = []): stat /** * Set the Precognition header to "true". + * + * @return $this */ public function withPrecognition(): static { @@ -678,7 +718,7 @@ protected function authenticationContextKeys(): array return []; } - return $auth->getAuthContextKeys(); /* @phpstan-ignore method.notFound */ + return $auth->getAuthContextKeys(); } /** diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php index 63ad5cefb5..10de901ce8 100644 --- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php +++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php @@ -64,6 +64,7 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m try { $callback(); } catch (Throwable $throwable) { + // @phpstan-ignore hypervel.cancellation.precedence (the test's own failure stays primary so PHPUnit can match its expected exception) $exception ??= $throwable; } }; diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index 56fe83e95d..8c4b35e51f 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\CachedConnectionResolver; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; @@ -90,7 +91,7 @@ public static function resetCachedConnections(): void try { static::discardCachedConnection($cacheKey); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } } @@ -355,7 +356,7 @@ protected static function discardCachedConnections(): void try { $pooled->discard(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/foundation/src/Testing/RedisTestConfiguration.php b/src/foundation/src/Testing/RedisTestConfiguration.php index e25ed4badc..0cf1295d35 100644 --- a/src/foundation/src/Testing/RedisTestConfiguration.php +++ b/src/foundation/src/Testing/RedisTestConfiguration.php @@ -5,6 +5,7 @@ namespace Hypervel\Foundation\Testing; use Hypervel\Contracts\Config\Repository; +use Hypervel\Support\Env; use RuntimeException; class RedisTestConfiguration @@ -14,8 +15,8 @@ class RedisTestConfiguration */ public static function isConfigured(): bool { - return env('REDIS_CLUSTER_HOSTS_AND_PORTS') !== null - || env('REDIS_HOST') !== null; + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Redis integration tests are configured through the test process environment) + return static::usesCluster() || Env::get('REDIS_HOST') !== null; } /** @@ -23,7 +24,8 @@ public static function isConfigured(): bool */ public static function usesCluster(): bool { - return env('REDIS_CLUSTER_HOSTS_AND_PORTS') !== null; + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Redis integration tests are configured through the test process environment) + return Env::get('REDIS_CLUSTER_HOSTS_AND_PORTS') !== null; } /** @@ -33,7 +35,8 @@ public static function usesCluster(): bool */ public static function clusterSeeds(): array { - $value = env('REDIS_CLUSTER_HOSTS_AND_PORTS'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Redis integration tests are configured through the test process environment) + $value = Env::get('REDIS_CLUSTER_HOSTS_AND_PORTS'); if (! is_string($value) || trim($value) === '') { throw new RuntimeException('REDIS_CLUSTER_HOSTS_AND_PORTS must be a comma-separated list of non-empty Redis Cluster seeds.'); diff --git a/src/foundation/src/Testing/RedisTestDatabases.php b/src/foundation/src/Testing/RedisTestDatabases.php index f696db112a..86a9501b58 100644 --- a/src/foundation/src/Testing/RedisTestDatabases.php +++ b/src/foundation/src/Testing/RedisTestDatabases.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing; +use Hypervel\Support\Env; use RuntimeException; class RedisTestDatabases @@ -37,7 +38,8 @@ public static function maximumDatabase(): int */ public static function configuredSecondaryDatabase(): ?int { - $value = env('REDIS_TEST_SECONDARY_DB'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Redis integration tests are configured through the test process environment) + $value = Env::get('REDIS_TEST_SECONDARY_DB'); return $value === null ? null @@ -137,7 +139,8 @@ public static function workerIndex(string $token): int */ public static function integerEnvironment(string $key, int $default): int { - $value = env($key); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Redis integration tests are configured through the test process environment) + $value = Env::get($key); if ($value === null) { return $default; diff --git a/src/foundation/src/Testing/TestCase.php b/src/foundation/src/Testing/TestCase.php index bffd593416..30dc404227 100644 --- a/src/foundation/src/Testing/TestCase.php +++ b/src/foundation/src/Testing/TestCase.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\BootProviders; @@ -115,11 +116,11 @@ protected function prepareApplicationForCachedState(ApplicationContract $app): v $this->traitsUsedByTest ??= class_uses_recursive(static::class); if (isset(CachedState::$cachedConfig, $this->traitsUsedByTest[WithCachedConfig::class])) { - $this->markConfigCached($app); // @phpstan-ignore method.notFound + $this->markConfigCached($app); // @phpstan-ignore method.notFound (provided by WithCachedConfig, checked above) } if (isset(CachedState::$cachedRoutes, $this->traitsUsedByTest[WithCachedRoutes::class])) { - $this->markRoutesCached($app); // @phpstan-ignore method.notFound + $this->markRoutesCached($app); // @phpstan-ignore method.notFound (provided by WithCachedRoutes, checked above) } } @@ -143,7 +144,7 @@ protected function tearDown(): void try { $this->tearDownTheTestEnvironmentUsingMockery(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/foundation/src/Vite.php b/src/foundation/src/Vite.php index f3edf171d5..7a4308d5d8 100644 --- a/src/foundation/src/Vite.php +++ b/src/foundation/src/Vite.php @@ -141,6 +141,8 @@ public function useCspNonce(?string $nonce = null): string * * Boot-only. The key is stored on the shared Vite instance and affects * every subsequent asset render by this worker. + * + * @return $this */ public function useIntegrityKey(string|false $key): static { @@ -151,6 +153,8 @@ public function useIntegrityKey(string|false $key): static /** * Set the Vite entry points. + * + * @return $this */ public function withEntryPoints(array $entryPoints): static { @@ -161,6 +165,8 @@ public function withEntryPoints(array $entryPoints): static /** * Merge additional Vite entry points with the current set. + * + * @return $this */ public function mergeEntryPoints(array $entryPoints): static { @@ -177,6 +183,8 @@ public function mergeEntryPoints(array $entryPoints): static * * Boot-only. The filename is stored on the shared Vite instance and affects * every subsequent manifest lookup by this worker. + * + * @return $this */ public function useManifestFilename(string $filename): static { @@ -190,6 +198,8 @@ public function useManifestFilename(string $filename): static * * Boot-only. The resolver is stored on the shared Vite instance and affects * every subsequent asset path generated by this worker. + * + * @return $this */ public function createAssetPathsUsing(?callable $resolver): static { @@ -211,6 +221,8 @@ public function hotFile(): string * * Boot-only. The path is stored on the shared Vite instance and affects * every subsequent asset render by this worker. + * + * @return $this */ public function useHotFile(string $path): static { @@ -224,6 +236,8 @@ public function useHotFile(string $path): static * * Boot-only. The directory is stored on the shared Vite instance and * affects every subsequent asset render by this worker. + * + * @return $this */ public function useBuildDirectory(string $path): static { @@ -239,6 +253,7 @@ public function useBuildDirectory(string $path): static * every subsequent asset render by this worker. * * @param array|(callable(string, string, ?array, ?array): array) $attributes + * @return $this */ public function useScriptTagAttributes(callable|array $attributes): static { @@ -258,6 +273,7 @@ public function useScriptTagAttributes(callable|array $attributes): static * every subsequent asset render by this worker. * * @param array|(callable(string, string, ?array, ?array): array) $attributes + * @return $this */ public function useStyleTagAttributes(callable|array $attributes): static { @@ -277,6 +293,7 @@ public function useStyleTagAttributes(callable|array $attributes): static * every subsequent asset render by this worker. * * @param array|(callable(string, string, ?array, ?array): (array|false))|false $attributes + * @return $this */ public function usePreloadTagAttributes(callable|array|false $attributes): static { @@ -294,6 +311,8 @@ public function usePreloadTagAttributes(callable|array|false $attributes): stati * * Boot-only. The strategy is stored on the shared Vite instance and affects * every subsequent asset render by this worker. + * + * @return $this */ public function prefetch(?int $concurrency = null, string $event = 'load'): static { @@ -309,6 +328,8 @@ public function prefetch(?int $concurrency = null, string $event = 'load'): stat * * Boot-only. The strategy is stored on the shared Vite instance and affects * every subsequent asset render by this worker. + * + * @return $this */ public function useWaterfallPrefetching(?int $concurrency = null): static { @@ -322,6 +343,8 @@ public function useWaterfallPrefetching(?int $concurrency = null): static * * Boot-only. The strategy is stored on the shared Vite instance and affects * every subsequent asset render by this worker. + * + * @return $this */ public function useAggressivePrefetching(): static { @@ -334,6 +357,8 @@ public function useAggressivePrefetching(): static * Boot-only. The strategy is stored on the shared Vite instance and affects * every subsequent asset render by this worker. * + * @return $this + * * @throws Exception */ public function usePrefetchStrategy(?string $strategy, array $config = []): static @@ -440,8 +465,8 @@ public function __invoke(string|array $entrypoints, ?string $buildDirectory = nu $base = $preloads->join('') . $stylesheets->join('') . $scripts->join(''); - /* @phpstan-ignore booleanOr.rightAlwaysFalse */ - if ($this->prefetchStrategy === null || $this->isRunningHot()) { + // Laravel also rechecks isRunningHot() here; that branch already returned above. + if ($this->prefetchStrategy === null) { return new HtmlString($base); } @@ -1104,6 +1129,8 @@ protected function viteFonts(): ViteFonts * * Boot-only. The filename is stored on the shared Vite instance and affects * every subsequent font manifest lookup by this worker. + * + * @return $this */ public function useFontsManifestFilename(string $filename): static { diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index f9823da9ac..9d5ba4ee77 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -43,6 +43,7 @@ use Hypervel\Session\SessionManager; use Hypervel\Support\Defer\DeferredCallback; use Hypervel\Support\Defer\DeferredCallbackCollection; +use Hypervel\Support\Env; use Hypervel\Support\Facades\Date; use Hypervel\Support\Facades\Route; use Hypervel\Support\HtmlString; @@ -161,7 +162,8 @@ function app(?string $abstract = null, array $parameters = []) */ function app_id(): string { - $id = env('APP_ID'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (app_id() supplies configuration defaults while configuration loads) + $id = Env::get('APP_ID'); if ($id !== null && $id !== '') { $id = (string) $id; @@ -175,7 +177,8 @@ function app_id(): string return $id; } - return Str::slug(Str::snake((string) env('APP_NAME', 'hypervel')), '_') ?: 'hypervel'; + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (app_id() supplies configuration defaults while configuration loads) + return Str::slug(Str::snake((string) Env::get('APP_NAME', 'hypervel')), '_') ?: 'hypervel'; } } @@ -343,7 +346,9 @@ function config(mixed $key = null, mixed $default = null): mixed } if (is_array($key)) { - return $config->set($key); + $config->set($key); + + return null; } return $config->get($key, $default); @@ -841,7 +846,7 @@ function resource_path(string $path = ''): string * * With no arguments, return the factory. Any argument, including null, creates a response. * - * @return ($content is null ? ResponseFactory : HypervelResponse) + * @return ($content is null ? HypervelResponse|ResponseFactory : HypervelResponse) */ function response(mixed $content = null, int $status = 200, array $headers = []): ResponseFactory|HypervelResponse { @@ -904,7 +909,9 @@ function session(array|UnitEnum|string|null $key = null, mixed $default = null): } if (is_array($key)) { - return $session->put($key); + $session->put($key); + + return null; } return $session->get($key, $default); @@ -1044,7 +1051,7 @@ function url(?string $path = null, mixed $parameters = [], ?bool $secure = null) * * With no arguments, return the factory. Any argument, including null, creates a validator. * - * @return ($data is null ? ValidatorFactoryContract : ValidatorContract) + * @return ($data is null ? ValidatorContract|ValidatorFactoryContract : ValidatorContract) */ function validator(?array $data = null, array $rules = [], array $messages = [], array $attributes = []): ValidatorContract|ValidatorFactoryContract { @@ -1062,6 +1069,9 @@ function validator(?array $data = null, array $rules = [], array $messages = [], /** * Get the evaluated view contents for the given view. * + * With no arguments, return the factory. Any argument makes a view, so an explicit + * null view name fails in make() rather than returning the factory. + * * @return ($view is null ? ViewFactory : ViewContract) */ function view(?string $view = null, array|Arrayable $data = [], array $mergeData = []): ViewFactory|ViewContract diff --git a/src/grpc/composer.json b/src/grpc/composer.json index 10248eee5c..baae1f9a4f 100644 --- a/src/grpc/composer.json +++ b/src/grpc/composer.json @@ -54,8 +54,8 @@ "hypervel/support": "^0.4", "nesbot/carbon": "^3.13.1", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "suggest": { "ext-protobuf": "Improves Protocol Buffers serialization performance." diff --git a/src/grpc/src/Client/BaseClient.php b/src/grpc/src/Client/BaseClient.php index 4b4f9721b5..32fa810c9c 100644 --- a/src/grpc/src/Client/BaseClient.php +++ b/src/grpc/src/Client/BaseClient.php @@ -26,7 +26,6 @@ use Hypervel\Grpc\StatusCode; use InvalidArgumentException; use LogicException; -use Swoole\Coroutine\CanceledException; use Throwable; abstract class BaseClient @@ -241,22 +240,15 @@ public function close(): void $this->connections = []; $this->retiringConnections = []; $failure = null; - $cancellation = null; foreach ($connections as $connection) { try { $connection->close(); - } catch (CanceledException $exception) { - $cancellation ??= $exception; } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } } - if ($cancellation !== null) { - throw $cancellation; - } - if ($failure !== null) { throw $failure; } @@ -503,10 +495,6 @@ private function startInitialAttempt( return [$state, $connection]; } catch (Throwable $throwable) { - if (Coroutine::causedByCancellation($throwable)) { - throw $throwable; - } - $operationHandle?->finish(new GrpcOperationResult( $throwable instanceof RpcException ? $throwable->status() : null, $throwable, diff --git a/src/grpc/src/Client/Call.php b/src/grpc/src/Client/Call.php index 7a0b34781e..3ffdb70181 100644 --- a/src/grpc/src/Client/Call.php +++ b/src/grpc/src/Client/Call.php @@ -962,14 +962,26 @@ private function closeWriteSemaphore(): void } /** - * Release unfinished native call resources. + * Finish a dropped call and release its unfinished native resources. */ public function __destruct() { try { - $this->state->abandonIfIncomplete(); + try { + if (! $this->finished) { + // A transport attempt that already completed keeps its result; work + // nobody waited for was abandoned and finishes without one. + $status = $this->state->finalStatus(); + $this->finish( + $status === null ? null : $this->resolveStatus($status), + $this->state->finalFailure(), + ); + } + } finally { + $this->state->abandonIfIncomplete(); + } } catch (Throwable) { - // Destructors cannot safely surface cleanup failures during process shutdown. + // Destructors cannot safely surface cleanup or observer failures during process shutdown. } } } diff --git a/src/grpc/src/Client/Connection.php b/src/grpc/src/Client/Connection.php index 6990e9939f..20bd21438d 100644 --- a/src/grpc/src/Client/Connection.php +++ b/src/grpc/src/Client/Connection.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Engine\Http\V2\ClientFactoryInterface; use Hypervel\Contracts\Engine\Http\V2\ClientInterface; use Hypervel\Contracts\Engine\Http\V2\ResponseInterface; +use Hypervel\Coroutine\Coroutine as FrameworkCoroutine; use Hypervel\Coroutine\Exceptions\ForeignCancellationException; use Hypervel\Database\ConnectionResolver; use Hypervel\Engine\Channel; @@ -229,9 +230,11 @@ public function start(Closure $requestFactory, StreamState $state, Deadline $dea try { $this->startReceiver(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { + if (FrameworkCoroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $this->terminateWhileLocked($this->connectionException( $throwable, 'Unable to start the gRPC response receiver.', @@ -776,10 +779,10 @@ private function terminateWhileLocked(ConnectionException $failure): ConnectionE if ($client->isConnected()) { $client->close(); } - } catch (CanceledException $exception) { - $cancellation ??= $exception; } catch (Throwable $throwable) { - if ($cancellation === null) { + if (FrameworkCoroutine::causedByCancellation($throwable)) { + $cancellation = FrameworkCoroutine::preferCancellation($cancellation, $throwable); + } elseif ($cancellation === null) { $failure = new ConnectionException( $this->endpoint->peer, $failure->getMessage() . ' The HTTP/2 client also failed to close: ' @@ -793,9 +796,8 @@ private function terminateWhileLocked(ConnectionException $failure): ConnectionE try { $this->notifyRetired(); - } catch (CanceledException $exception) { - $cancellation ??= $exception; } catch (Throwable $throwable) { + // A pending cancellation stays primary over any retirement failure. if ($cancellation === null) { throw $throwable; } diff --git a/src/grpc/src/Contracts/GrpcOperationObserver.php b/src/grpc/src/Contracts/GrpcOperationObserver.php index 96de8b3635..59ea56724e 100644 --- a/src/grpc/src/Contracts/GrpcOperationObserver.php +++ b/src/grpc/src/Contracts/GrpcOperationObserver.php @@ -16,6 +16,8 @@ public function starting(GrpcOperation $operation): mixed; /** * Finish observing a logical gRPC operation. + * + * Every started observer is finished; the result carries the operation's failure or cancellation, if any. */ public function finished( GrpcOperation $operation, diff --git a/src/grpc/src/GrpcOperationHandle.php b/src/grpc/src/GrpcOperationHandle.php index c4d1f43cbc..4e99c7e8bd 100644 --- a/src/grpc/src/GrpcOperationHandle.php +++ b/src/grpc/src/GrpcOperationHandle.php @@ -6,6 +6,7 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Grpc\Contracts\GrpcOperationObserver; +use Hypervel\Support\FailureReporter; use Throwable; /** @@ -50,20 +51,26 @@ public function finish(GrpcOperationResult $result): void try { $observer->finished($this->operation, $token, $result); } catch (Throwable $throwable) { - if (Coroutine::causedByCancellation($throwable)) { - throw $throwable; + // An observer that rethrows the operation's own failure adds none; its owner handles it. + if ($throwable !== $result->exception) { + $observerFailure = FailureReporter::preferCancellation($observerFailure, $throwable); } - - $observerFailure ??= $throwable; } } - if ( - $observerFailure !== null - && $result->exception === null - && ($result->status === null || $result->status->isOk()) + if ($observerFailure === null) { + return; + } + + // An observer's cancellation propagates unless the operation already carries one; + // any other observer failure only replaces a successful result, and is reported otherwise. + if (Coroutine::causedByCancellation($observerFailure) + ? $result->exception === null || ! Coroutine::causedByCancellation($result->exception) + : $result->exception === null && ($result->status === null || $result->status->isOk()) ) { throw $observerFailure; } + + FailureReporter::report($observerFailure); } } diff --git a/src/grpc/src/GrpcOperationRunner.php b/src/grpc/src/GrpcOperationRunner.php index c611020ec5..a23b0ce27d 100644 --- a/src/grpc/src/GrpcOperationRunner.php +++ b/src/grpc/src/GrpcOperationRunner.php @@ -4,7 +4,6 @@ namespace Hypervel\Grpc; -use Hypervel\Coroutine\Coroutine; use Hypervel\Grpc\Contracts\GrpcOperationObserver; use Throwable; @@ -44,10 +43,7 @@ public function start(GrpcOperation $operation): GrpcOperationHandle $startedObservers[] = [$observer, $observer->starting($operation)]; } } catch (Throwable $throwable) { - if (Coroutine::causedByCancellation($throwable)) { - throw $throwable; - } - + // Observers that started finish with the failure or cancellation. (new GrpcOperationHandle($operation, $startedObservers))->finish( new GrpcOperationResult(null, $throwable, 0), ); diff --git a/src/grpc/src/Protocol/MessageSerializer.php b/src/grpc/src/Protocol/MessageSerializer.php index 7e703a718a..b19f731e7f 100644 --- a/src/grpc/src/Protocol/MessageSerializer.php +++ b/src/grpc/src/Protocol/MessageSerializer.php @@ -8,7 +8,6 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Grpc\Exceptions\ProtocolException; use InvalidArgumentException; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -23,9 +22,11 @@ public static function serialize(Message $message): string { try { return $message->serializeToString(); - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + throw new ProtocolException( 'Unable to serialize the gRPC message.', previous: $throwable, @@ -80,9 +81,11 @@ public static function deserialize(array|callable $deserialize, string $payload) try { $message = new $messageClass; $message->mergeFromString($payload); - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + throw new ProtocolException( 'Unable to deserialize the gRPC message.', previous: $throwable, diff --git a/src/grpc/src/Server/ExceptionMapper.php b/src/grpc/src/Server/ExceptionMapper.php index fc18a1a94c..76580a4523 100644 --- a/src/grpc/src/Server/ExceptionMapper.php +++ b/src/grpc/src/Server/ExceptionMapper.php @@ -5,6 +5,7 @@ namespace Hypervel\Grpc\Server; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Coroutine\Coroutine; use Hypervel\Grpc\Exceptions\ProtocolException; use Hypervel\Grpc\Exceptions\RpcException; use Hypervel\Grpc\StatusCode; @@ -27,7 +28,7 @@ public function __construct(private readonly ExceptionHandler $exceptions) */ public function map(Throwable $throwable): RpcException { - if ($throwable instanceof CanceledException) { + if (Coroutine::causedByCancellation($throwable)) { throw $throwable; } @@ -49,7 +50,7 @@ public function map(Throwable $throwable): RpcException */ public function invalidResponse(Throwable $throwable): RpcException { - if ($throwable instanceof CanceledException) { + if (Coroutine::causedByCancellation($throwable)) { throw $throwable; } @@ -63,15 +64,18 @@ public function invalidResponse(Throwable $throwable): RpcException */ public function report(Throwable $throwable): void { + // @phpstan-ignore hypervel.cancellation.exactType (a direct cancellation is never reported; a failure carrying it is reported) if ($throwable instanceof CanceledException) { throw $throwable; } try { $this->exceptions->report($throwable); - } catch (CanceledException $throwable) { - throw $throwable; - } catch (Throwable) { + } catch (Throwable $reportFailure) { + if (Coroutine::causedByCancellation($reportFailure)) { + throw $reportFailure; + } + try { error_log((string) $throwable); } catch (Throwable) { diff --git a/src/grpc/src/Server/Middleware/HandleCall.php b/src/grpc/src/Server/Middleware/HandleCall.php index 70d9d3232d..72d60240a2 100644 --- a/src/grpc/src/Server/Middleware/HandleCall.php +++ b/src/grpc/src/Server/Middleware/HandleCall.php @@ -140,6 +140,7 @@ static function (bool $closing) use ($handlerId): void { throw $exception; } + // @phpstan-ignore hypervel.cancellation.exactType (the deadline's own cancellation is expected; a failure carrying it is reported) if (! $exception instanceof CanceledException) { $this->exceptions->report($exception); } diff --git a/src/grpc/src/Server/ResponseFactory.php b/src/grpc/src/Server/ResponseFactory.php index d3e6d1c1c6..8ef4dc8752 100644 --- a/src/grpc/src/Server/ResponseFactory.php +++ b/src/grpc/src/Server/ResponseFactory.php @@ -578,6 +578,7 @@ private function mapStreamFailure( ): RpcException { if (Coroutine::causedByCancellation($throwable)) { if ($context->deadlineExceeded()) { + // @phpstan-ignore hypervel.cancellation.exactType (the deadline's own cancellation is expected; a failure carrying it is reported) if (! $throwable instanceof CanceledException) { $this->exceptions->report($throwable); } diff --git a/src/grpc/src/Server/Server.php b/src/grpc/src/Server/Server.php index 2eea497b27..22caec2478 100644 --- a/src/grpc/src/Server/Server.php +++ b/src/grpc/src/Server/Server.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Server\OnRequestInterface; use Hypervel\Coordinator\Constants; use Hypervel\Coordinator\CoordinatorManager; +use Hypervel\Coroutine\Coroutine; use Hypervel\Grpc\Exceptions\ProtocolException; use Hypervel\Grpc\Exceptions\RpcException; use Hypervel\Grpc\GrpcOperationResult; @@ -24,8 +25,8 @@ use Hypervel\HttpServer\RequestBridge; use Hypervel\HttpServer\ResponseBridge; use Hypervel\Server\TlsOptions; +use Hypervel\Support\FailureReporter; use InvalidArgumentException; -use Swoole\Coroutine\CanceledException; use Swoole\Http\Request as SwooleRequest; use Swoole\Http\Response as SwooleResponse; use Symfony\Component\HttpFoundation\Response; @@ -98,7 +99,7 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe $operationHandle = null; $terminalException = null; $emissionFailure = null; - $cancelled = false; + $cancellation = null; try { CoordinatorManager::until(Constants::WORKER_START)->yield(); @@ -136,11 +137,13 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe $emissionStarted = true; ResponseBridge::send($response, $swooleResponse, protocol: 'HTTP/2', request: $request ?? null); - } catch (CanceledException $exception) { - $cancelled = true; - - throw $exception; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + + throw $throwable; + } + $terminalException = $throwable; if ($response instanceof GrpcStreamedResponse) { @@ -159,11 +162,13 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe $response = $this->responses->error($this->exceptions->map($throwable)); $emissionStarted = true; ResponseBridge::send($response, $swooleResponse, protocol: 'HTTP/2', request: $request ?? null); - } catch (CanceledException $exception) { - $cancelled = true; - - throw $exception; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + + throw $throwable; + } + $emissionFailure = $throwable; $this->exceptions->report($emissionFailure); $this->completeWritableResponse($swooleResponse); @@ -175,12 +180,16 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe $this->contexts->forget(); - if (! $cancelled && $operationHandle !== null) { - $operationHandle->finish($this->operationResult( - $response, - $terminalException, - $emissionFailure, - )); + if ($operationHandle !== null && $cancellation === null) { + $operationHandle->finish($this->operationResult($response, $terminalException, $emissionFailure)); + } elseif ($operationHandle !== null) { + // A canceled call sent no final status; a handler failure that preceded it stays the outcome. + try { + $operationHandle->finish(new GrpcOperationResult(null, $terminalException ?? $cancellation, 1)); + } catch (Throwable $throwable) { + // The unwinding cancellation keeps precedence; an observer failure is still reported. + FailureReporter::report($throwable); + } } } } diff --git a/src/hashing/src/ArgonHasher.php b/src/hashing/src/ArgonHasher.php index 8ddf580863..9e16c80b5c 100644 --- a/src/hashing/src/ArgonHasher.php +++ b/src/hashing/src/ArgonHasher.php @@ -214,7 +214,7 @@ protected function time(array $options): int */ protected function threads(array $options): int { - if (defined('PASSWORD_ARGON2_PROVIDER') && PASSWORD_ARGON2_PROVIDER === 'sodium') { // @phpstan-ignore identical.alwaysFalse, booleanAnd.alwaysFalse (platform-specific constant) + if (defined('PASSWORD_ARGON2_PROVIDER') && PASSWORD_ARGON2_PROVIDER === 'sodium') { // @phpstan-ignore identical.alwaysFalse (platform-specific constant), booleanAnd.alwaysFalse (platform-specific constant) return 1; } diff --git a/src/head/composer.json b/src/head/composer.json index fde356bcde..8ef40c78fc 100644 --- a/src/head/composer.json +++ b/src/head/composer.json @@ -38,8 +38,8 @@ "hypervel/support": "^0.4", "hypervel/view": "^0.4", "psr/log": "^3.0", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "suggest": { "hypervel/inertia": "Required for Inertia head management (^0.4)." @@ -63,11 +63,6 @@ "aliases": { "Head": "Hypervel\\Head\\Facades\\Head" } - }, - "phpstan": { - "includes": [ - "extension.neon" - ] } } } diff --git a/src/head/extension.neon b/src/head/extension.neon deleted file mode 100644 index 392e16dc33..0000000000 --- a/src/head/extension.neon +++ /dev/null @@ -1,5 +0,0 @@ -services: - - - class: Hypervel\Head\PHPStan\WithHeadExtension - tags: - - phpstan.broker.methodsClassReflectionExtension diff --git a/src/horizon/composer.json b/src/horizon/composer.json index bdf52e51c1..ed880e64f6 100644 --- a/src/horizon/composer.json +++ b/src/horizon/composer.json @@ -45,14 +45,15 @@ "hypervel/notifications": "^0.4", "hypervel/queue": "^0.4", "hypervel/redis": "^0.4", + "hypervel/reflection": "^0.4", "hypervel/routing": "^0.4", "hypervel/sentinel": "^0.4", "hypervel/slack-notification-channel": "^0.4", "hypervel/support": "^0.4", "hypervel/watcher": "^0.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/process": "^8.1" }, "autoload": { diff --git a/src/horizon/src/AutoScaler.php b/src/horizon/src/AutoScaler.php index 22b188a2c3..d3ff8dc98e 100644 --- a/src/horizon/src/AutoScaler.php +++ b/src/horizon/src/AutoScaler.php @@ -58,7 +58,7 @@ protected function timeToClearPerQueue(Supervisor $supervisor, Collection $pools { return $pools->mapWithKeys(function ($pool, $queue) use ($supervisor) { // Numeric queue names become integer collection keys. - $queues = collect(explode(',', (string) $queue))->map(function ($_queue) use ($supervisor) { // @phpstan-ignore argument.unresolvableType + $queues = collect(explode(',', (string) $queue))->map(function ($_queue) use ($supervisor) { // @phpstan-ignore argument.unresolvableType (the callback's size comes from readyNow(), which the Queue contract omits) // @phpstan-ignore method.notFound (RedisQueue has readyNow method) $size = $this->queue->connection($supervisor->options->connection)->readyNow($_queue); diff --git a/src/horizon/src/Console/ContinueSupervisorCommand.php b/src/horizon/src/Console/ContinueSupervisorCommand.php index 1923f016dc..6ecef9c1bb 100644 --- a/src/horizon/src/Console/ContinueSupervisorCommand.php +++ b/src/horizon/src/Console/ContinueSupervisorCommand.php @@ -29,7 +29,7 @@ class ContinueSupervisorCommand extends Command */ public function handle(SupervisorRepository $supervisors): int { - $processId = (int) optional(collect($supervisors->all())->first(function ($supervisor) { // @phpstan-ignore property.notFound (optional() __get proxy) + $processId = (int) optional(collect($supervisors->all())->first(function ($supervisor) { return Str::startsWith($supervisor->name, MasterSupervisor::basename()) && Str::endsWith($supervisor->name, $this->argument('name')); }))->pid; diff --git a/src/horizon/src/Console/HorizonRestartStrategy.php b/src/horizon/src/Console/HorizonRestartStrategy.php index 7ca5e67985..e400347209 100644 --- a/src/horizon/src/Console/HorizonRestartStrategy.php +++ b/src/horizon/src/Console/HorizonRestartStrategy.php @@ -49,6 +49,7 @@ public function start(): void $this->output->write($line); }); + // @phpstan-ignore hypervel.rawSleep (gives the started Horizon process real time to fail) usleep(100_000); if ($this->horizonProcess->isTerminated()) { diff --git a/src/horizon/src/Console/PauseSupervisorCommand.php b/src/horizon/src/Console/PauseSupervisorCommand.php index 6555e687af..87c16025f0 100644 --- a/src/horizon/src/Console/PauseSupervisorCommand.php +++ b/src/horizon/src/Console/PauseSupervisorCommand.php @@ -29,7 +29,7 @@ class PauseSupervisorCommand extends Command */ public function handle(SupervisorRepository $supervisors): int { - $processId = (int) optional(collect($supervisors->all())->first(function ($supervisor) { // @phpstan-ignore property.notFound (optional() __get proxy) + $processId = (int) optional(collect($supervisors->all())->first(function ($supervisor) { return Str::startsWith($supervisor->name, MasterSupervisor::basename()) && Str::endsWith($supervisor->name, $this->argument('name')); }))->pid; diff --git a/src/horizon/src/Console/SupervisorStatusCommand.php b/src/horizon/src/Console/SupervisorStatusCommand.php index e557642fe8..9ca376fce6 100644 --- a/src/horizon/src/Console/SupervisorStatusCommand.php +++ b/src/horizon/src/Console/SupervisorStatusCommand.php @@ -31,7 +31,7 @@ public function handle(SupervisorRepository $supervisors): int { $name = $this->argument('name'); - $supervisorStatus = optional(collect($supervisors->all())->first(function ($supervisor) use ($name) { // @phpstan-ignore property.notFound (optional() __get proxy) + $supervisorStatus = optional(collect($supervisors->all())->first(function ($supervisor) use ($name) { return Str::startsWith($supervisor->name, MasterSupervisor::basename()) && Str::endsWith($supervisor->name, $name); }))->status; diff --git a/src/horizon/src/Events/JobsMigrated.php b/src/horizon/src/Events/JobsMigrated.php index fb022e6e50..38f45d7528 100644 --- a/src/horizon/src/Events/JobsMigrated.php +++ b/src/horizon/src/Events/JobsMigrated.php @@ -41,6 +41,8 @@ public function __construct(array $payloads) /** * Set the connection name. + * + * @return $this */ public function connection(string $connectionName): static { @@ -51,6 +53,8 @@ public function connection(string $connectionName): static /** * Set the queue name. + * + * @return $this */ public function queue(string $queue): static { diff --git a/src/horizon/src/Events/RedisEvent.php b/src/horizon/src/Events/RedisEvent.php index 21879e28cb..6dbce41cac 100644 --- a/src/horizon/src/Events/RedisEvent.php +++ b/src/horizon/src/Events/RedisEvent.php @@ -33,6 +33,8 @@ public function __construct(string $payload) /** * Set the connection name. + * + * @return $this */ public function connection(string $connectionName): static { @@ -43,6 +45,8 @@ public function connection(string $connectionName): static /** * Set the queue name. + * + * @return $this */ public function queue(string $queue): static { diff --git a/src/horizon/src/Events/SupervisorOutOfMemory.php b/src/horizon/src/Events/SupervisorOutOfMemory.php index 0bf31d283b..de185894c7 100644 --- a/src/horizon/src/Events/SupervisorOutOfMemory.php +++ b/src/horizon/src/Events/SupervisorOutOfMemory.php @@ -33,6 +33,8 @@ public function getMemoryUsage(): float|int /** * Set the memory usage that was recorded when the event was dispatched. + * + * @return $this */ public function setMemoryUsage(float|int $memoryUsage): static { diff --git a/src/horizon/src/Http/Controllers/WorkloadController.php b/src/horizon/src/Http/Controllers/WorkloadController.php index a29932a7a0..4e0528f414 100644 --- a/src/horizon/src/Http/Controllers/WorkloadController.php +++ b/src/horizon/src/Http/Controllers/WorkloadController.php @@ -13,6 +13,6 @@ class WorkloadController extends Controller */ public function index(WorkloadRepository $workload): array { - return collect($workload->get())->sortBy('name')->values()->toArray(); + return collect($workload->get())->sortBy('name')->values()->all(); } } diff --git a/src/horizon/src/JobPayload.php b/src/horizon/src/JobPayload.php index 995845858e..3e5f0eea7d 100644 --- a/src/horizon/src/JobPayload.php +++ b/src/horizon/src/JobPayload.php @@ -92,6 +92,8 @@ public function isSilenced(): bool /** * Prepare the payload for storage on the queue by adding tags, etc. + * + * @return $this */ public function prepare(mixed $job): static { @@ -103,6 +105,8 @@ public function prepare(mixed $job): static /** * Prepare the payload metadata while the original job is available. + * + * @return $this */ public function prepareForQueueing(mixed $job): static { @@ -131,6 +135,8 @@ protected function queueingMetadata(mixed $job): array /** * Mark the payload as published to Redis. + * + * @return $this */ public function markAsPushed(): static { @@ -198,6 +204,8 @@ protected function underlyingJob(mixed $job): mixed /** * Set the given key / value pairs on the payload. + * + * @return $this */ public function set(array $values): static { diff --git a/src/horizon/src/MasterSupervisor.php b/src/horizon/src/MasterSupervisor.php index 5f67803ea5..937d946741 100644 --- a/src/horizon/src/MasterSupervisor.php +++ b/src/horizon/src/MasterSupervisor.php @@ -176,6 +176,7 @@ public function terminate(int $status = 0): void break; } + // @phpstan-ignore hypervel.rawSleep (waits in real time for supervisor processes to exit) sleep(1); } @@ -199,6 +200,7 @@ public function monitor(): int $this->persist(); while (true) { + // @phpstan-ignore hypervel.rawSleep (the monitor loop polls its processes in real time) sleep(1); $this->loop(); @@ -317,6 +319,8 @@ public static function commandQueueFor(?string $name = null): string /** * Set the output handler. + * + * @return $this */ public function handleOutputUsing(Closure $callback): static { diff --git a/src/horizon/src/ProcessPool.php b/src/horizon/src/ProcessPool.php index 365adc4f4a..3a29b272d1 100644 --- a/src/horizon/src/ProcessPool.php +++ b/src/horizon/src/ProcessPool.php @@ -128,6 +128,8 @@ protected function removeProcesses(int $count): void /** * Add a new worker process to the pool. + * + * @return $this */ protected function start(): static { diff --git a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php index 8dd123f45c..9e358f5cb3 100644 --- a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php +++ b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php @@ -84,7 +84,6 @@ public function get(array $names): array */ public function update(MasterSupervisor $master): void { - // @phpstan-ignore method.nonObject (HigherOrderProxy: ->map->name returns Collection, not string) $supervisors = $master->supervisors->map->name->all(); $this->pipeline(function ($pipe) use ($master, $supervisors) { diff --git a/src/horizon/src/Repositories/RedisWorkloadRepository.php b/src/horizon/src/Repositories/RedisWorkloadRepository.php index 61ed1724cb..5a9bf55c5e 100644 --- a/src/horizon/src/Repositories/RedisWorkloadRepository.php +++ b/src/horizon/src/Repositories/RedisWorkloadRepository.php @@ -49,9 +49,9 @@ public function get(): array $totalProcesses = $processes[$queue] ?? 0; $length = ! Str::contains($queue, ',') - ? collect([$queueName => $this->queue->connection($connection)->readyNow($queueName)]) // @phpstan-ignore method.notFound + ? collect([$queueName => $this->queue->connection($connection)->readyNow($queueName)]) // @phpstan-ignore method.notFound (RedisQueue has readyNow method) : collect(explode(',', $queueName))->mapWithKeys(function ($queueName) use ($connection) { - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (RedisQueue has readyNow method) return [$queueName => $this->queue->connection($connection)->readyNow($queueName)]; }); @@ -73,7 +73,7 @@ public function get(): array 'processes' => $totalProcesses, 'split_queues' => $splitQueues, ]; - })->values()->toArray(); + })->values()->all(); } /** diff --git a/src/horizon/src/Supervisor.php b/src/horizon/src/Supervisor.php index 039af4ec93..9d1aa30695 100644 --- a/src/horizon/src/Supervisor.php +++ b/src/horizon/src/Supervisor.php @@ -190,6 +190,7 @@ public function terminate(int $status = 0): void if ($this->shouldWait()) { while ($this->processPools->map->runningProcesses()->collapse()->isNotEmpty()) { + // @phpstan-ignore hypervel.rawSleep (waits in real time for worker processes to exit) sleep(1); } } @@ -219,6 +220,7 @@ public function monitor(): int $this->persist(); while (true) { + // @phpstan-ignore hypervel.rawSleep (the monitor loop polls its processes in real time) sleep(1); $this->loop(); @@ -398,6 +400,8 @@ public function isPaused(): bool /** * Set the output handler. + * + * @return $this */ public function handleOutputUsing(Closure $callback): static { diff --git a/src/horizon/src/Tags.php b/src/horizon/src/Tags.php index 18206fa063..7683a226d1 100644 --- a/src/horizon/src/Tags.php +++ b/src/horizon/src/Tags.php @@ -11,6 +11,7 @@ use Hypervel\Events\CallQueuedListener; use Hypervel\Mail\SendQueuedMailable; use Hypervel\Notifications\SendQueuedNotifications; +use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\Collection; use ReflectionClass; use ReflectionProperty; @@ -96,8 +97,9 @@ public static function modelsFor(array $targets): Collection $models = []; foreach ($targets as $target) { + // Unlike Laravel, a parent's private properties are read too, since the target holds them. $models[] = collect( - (new ReflectionClass($target))->getProperties() + ClassMetadataCache::allProperties($target) )->map(function ($property) use ($target) { $value = static::getValue($property, $target); diff --git a/src/horizon/src/WorkerProcess.php b/src/horizon/src/WorkerProcess.php index 97306884cf..556f065f23 100644 --- a/src/horizon/src/WorkerProcess.php +++ b/src/horizon/src/WorkerProcess.php @@ -52,6 +52,8 @@ public function __construct( /** * Start the process. + * + * @return $this */ public function start(Closure $callback): static { @@ -212,6 +214,8 @@ public function coolingDown(): bool /** * Set the output handler. + * + * @return $this */ public function handleOutputUsing(Closure $callback): static { diff --git a/src/http-server/composer.json b/src/http-server/composer.json index 55ca144ebe..61ea808899 100644 --- a/src/http-server/composer.json +++ b/src/http-server/composer.json @@ -38,7 +38,8 @@ "hypervel/coroutine": "^0.4", "hypervel/http": "^0.4", "hypervel/server": "^0.4", - "symfony/http-foundation": "^8.1" + "hypervel/support": "^0.4", + "symfony/http-foundation": "^8.1.11" }, "config": { "sort-packages": true diff --git a/src/http-server/src/Events/RequestCanceled.php b/src/http-server/src/Events/RequestCanceled.php new file mode 100644 index 0000000000..c8294d3de9 --- /dev/null +++ b/src/http-server/src/Events/RequestCanceled.php @@ -0,0 +1,34 @@ +rawContent(); $headers = static::transformHeaders($swooleRequest->header ?? [], $server); - return new Request( + $request = new Request( query: $swooleRequest->get ?? [], request: $swooleRequest->post ?? [], attributes: [], @@ -45,6 +45,15 @@ public static function createFromSwoole(SwooleRequest $swooleRequest): Request headers: $headers, pathInfo: static::extractPathInfo($server), ); + + // As in Request::createFromBase() and Laravel's capture(), a JSON payload fills the + // request bag, which then doubles as the JSON cache, so post() reads it too. + if ($request->isJson()) { + $request->request->replace($request->json()->all()); + $request->setJson($request->request); + } + + return $request; } /** diff --git a/src/http-server/src/ResponseBridge.php b/src/http-server/src/ResponseBridge.php index e5538d80ee..83e9a5e68f 100644 --- a/src/http-server/src/ResponseBridge.php +++ b/src/http-server/src/ResponseBridge.php @@ -5,6 +5,7 @@ namespace Hypervel\HttpServer; use Hypervel\Contracts\Http\HasTrailers; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\IterableStreamedResponse; use Hypervel\Server\ResponseCancellation; use ReflectionProperty; @@ -219,7 +220,7 @@ protected static function sendBinaryFile( try { static::end($swooleResponse); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($shouldDelete) { @@ -230,7 +231,7 @@ protected static function sendBinaryFile( throw new RuntimeException('Unable to delete the binary response file.'); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -371,7 +372,7 @@ protected static function streamBinaryFile( throw new RuntimeException('Unable to close the binary response file.'); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -535,7 +536,7 @@ protected static function sendStreamedContent(StreamedResponse $response, Swoole try { $writable = $swooleResponse->write($chunk); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); $writable = false; } @@ -546,7 +547,7 @@ protected static function sendStreamedContent(StreamedResponse $response, Swoole try { $response->sendContent(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } finally { static::restoreOutputBufferLevel($level); @@ -554,7 +555,7 @@ protected static function sendStreamedContent(StreamedResponse $response, Swoole try { $swooleResponse->end(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -590,7 +591,7 @@ protected static function sendIterableContent(IterableStreamedResponse $response try { $swooleResponse->end(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/http-server/src/Server.php b/src/http-server/src/Server.php index a0645ec476..6eff21c1f4 100644 --- a/src/http-server/src/Server.php +++ b/src/http-server/src/Server.php @@ -13,10 +13,12 @@ use Hypervel\Coordinator\Constants; use Hypervel\Coordinator\CoordinatorManager; use Hypervel\Coroutine\Coroutine; +use Hypervel\HttpServer\Events\RequestCanceled; use Hypervel\HttpServer\Events\RequestHandled; use Hypervel\HttpServer\Events\RequestReceived; use Hypervel\HttpServer\Events\RequestTerminated; use Hypervel\HttpServer\Events\ResponseSent; +use Hypervel\Support\FailureReporter; use Swoole\Coroutine\CanceledException; use Swoole\Http\Request as SwooleRequest; use Swoole\Http\Response as SwooleResponse; @@ -78,6 +80,7 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe $response = null; $exception = null; $cancellation = null; + $responseSent = false; try { if (! $this->workerStarted) { @@ -132,7 +135,7 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe if (Coroutine::causedByCancellation($throwable)) { $cancellation = $throwable; } else { - $exception ??= $throwable; + $exception = FailureReporter::preferCancellation($exception, $throwable); } } } @@ -153,9 +156,11 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe if (Coroutine::causedByCancellation($throwable)) { $cancellation = $throwable; } else { - $exception ??= $throwable; + $exception = FailureReporter::preferCancellation($exception, $throwable); } } + + $responseSent = $cancellation === null; } if (isset($request) && $cancellation === null) { @@ -172,7 +177,7 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe if (Coroutine::causedByCancellation($throwable)) { $cancellation = $throwable; } else { - $exception ??= $throwable; + $exception = FailureReporter::preferCancellation($exception, $throwable); } } } @@ -185,7 +190,7 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe if (Coroutine::causedByCancellation($throwable)) { $cancellation = $throwable; } else { - $exception ??= $throwable; + $exception = FailureReporter::preferCancellation($exception, $throwable); } } } @@ -204,12 +209,34 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe if (Coroutine::causedByCancellation($throwable)) { $cancellation = $throwable; } else { - $exception ??= $throwable; + $exception = FailureReporter::preferCancellation($exception, $throwable); } } } if ($cancellation !== null) { + if (isset($request)) { + try { + if ($this->event?->hasListeners(RequestCanceled::class)) { + $this->event->dispatch(new RequestCanceled( + request: $request, + response: $responseSent ? $response : null, + cancellation: $cancellation, + exception: $exception, + server: $this->serverName + )); + } + } catch (Throwable $throwable) { + // The cancellation keeps precedence; the listener failure is still reported. + FailureReporter::report($throwable); + } + } + + // The native boundary would have reported this failure if cancellation had not displaced it. + if ($exception !== null) { + FailureReporter::report($exception); + } + throw $cancellation; } diff --git a/src/http/composer.json b/src/http/composer.json index 9cd107f484..ee510cded8 100644 --- a/src/http/composer.json +++ b/src/http/composer.json @@ -47,8 +47,8 @@ "hypervel/support": "^0.4", "nesbot/carbon": "^3.13.1", "psr/http-message": "^2.0", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/mime": "^8.1" }, "suggest": { diff --git a/src/http/src/Client/CurlStreamingBody.php b/src/http/src/Client/CurlStreamingBody.php index d3c7617d14..754c37d4be 100644 --- a/src/http/src/Client/CurlStreamingBody.php +++ b/src/http/src/Client/CurlStreamingBody.php @@ -15,6 +15,7 @@ use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\BufferStream; use GuzzleHttp\TransferStats; +use Hypervel\Coroutine\Coroutine as FrameworkCoroutine; use Hypervel\Database\ConnectionResolver; use Hypervel\Engine\Coroutine; use Hypervel\ObjectPool\PoolErrorReporter; @@ -132,9 +133,11 @@ public function response(): ResponseInterface if ($onHeaders !== null) { try { $onHeaders($response, $this->request); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (FrameworkCoroutine::causedByCancellation($exception)) { + throw $exception; + } + throw $this->responseException('An error was encountered during the on_headers event', $response, $exception); } } @@ -148,9 +151,11 @@ public function response(): ResponseInterface try { $onTrailers(...$trailerArguments); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (FrameworkCoroutine::causedByCancellation($exception)) { + throw $exception; + } + throw $this->responseException('An error was encountered during the on_trailers event', $response, $exception); } } @@ -328,6 +333,7 @@ protected function advance(): void $this->completedResponse = CurlFactory::finish($retry, $easy, $this->factory)->wait(); } catch (TransferException $exception) { // Completion callbacks run after the native cancellation check above. + // @phpstan-ignore hypervel.cancellation.exactType (restores the direct cancellation Guzzle wrapped; a deeper carrier still propagates) if ($exception->getPrevious() instanceof CanceledException) { throw $exception->getPrevious(); } @@ -370,6 +376,7 @@ protected function wait(?float &$idleDeadline, bool $headers = false): void if ($selected === -1) { // libcurl can briefly have no descriptors while scheduling its next action. + // @phpstan-ignore hypervel.rawSleep (libcurl needs real time to schedule its next action) usleep(1000); } elseif ($selected > 0 && $this->readTimeout > 0) { $idleDeadline = hrtime(true) / 1e9 + $this->readTimeout; diff --git a/src/http/src/Client/CurlStreamingHandler.php b/src/http/src/Client/CurlStreamingHandler.php index 9a0aa7997a..b9f3ad25a8 100644 --- a/src/http/src/Client/CurlStreamingHandler.php +++ b/src/http/src/Client/CurlStreamingHandler.php @@ -23,7 +23,6 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Swoole\Runtime; use Throwable; @@ -159,9 +158,11 @@ public function __invoke(RequestInterface $request, array $options): PromiseInte } return Create::promiseFor($body->response()); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return Create::rejectionFor($exception); } } diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php index 195a5d64a2..713bc19c1a 100644 --- a/src/http/src/Client/Factory.php +++ b/src/http/src/Client/Factory.php @@ -32,7 +32,7 @@ use Psr\Http\Message\StreamInterface; /** - * @mixin PendingRequest + * @mixin PendingRequest */ class Factory { @@ -112,6 +112,8 @@ public function __construct(protected ?Dispatcher $dispatcher = null) * * Boot-only. The middleware persists on the factory for the worker lifetime * and affects every subsequently created pending request. + * + * @return $this */ public function globalMiddleware(callable $middleware): static { @@ -125,6 +127,8 @@ public function globalMiddleware(callable $middleware): static * * Boot-only. The middleware persists on the factory for the worker lifetime * and affects every subsequently created pending request. + * + * @return $this */ public function globalRequestMiddleware(callable $middleware): static { @@ -138,6 +142,8 @@ public function globalRequestMiddleware(callable $middleware): static * * Boot-only. The middleware persists on the factory for the worker lifetime * and affects every subsequently created pending request. + * + * @return $this */ public function globalResponseMiddleware(callable $middleware): static { @@ -151,6 +157,8 @@ public function globalResponseMiddleware(callable $middleware): static * * Boot-only. The options persist on the factory for the worker lifetime * and affect every subsequently created pending request. + * + * @return $this */ public function globalOptions(array|Closure $options): static { @@ -330,6 +338,8 @@ public function sequence(array $responses = []): ResponseSequence * * Tests only. Stubs persist on the factory for the worker lifetime * and affect every subsequently created pending request. + * + * @return $this */ public function fake(array|callable|null $callback = null): static { @@ -394,6 +404,8 @@ public function fakeSequence(string $url = '*'): ResponseSequence * Tests only. The stub persists on the factory for the worker lifetime * and affects every subsequently created pending request. * + * @return $this + * * @throws InvalidArgumentException */ public function stubUrl(string $url, array|callable|int|PromiseInterface|Response|string $callback): static @@ -431,6 +443,8 @@ public function stubUrl(string $url, array|callable|int|PromiseInterface|Respons * * Boot or tests only. The policy persists on the factory for the worker lifetime * and affects every subsequently created pending request. + * + * @return $this */ public function preventStrayRequests(bool $prevent = true): static { @@ -452,6 +466,8 @@ public function preventingStrayRequests(): bool * * Boot or tests only. The policy persists on the factory for the worker lifetime * and affects every subsequently created pending request. + * + * @return $this */ public function allowStrayRequests(?array $only = null): static { @@ -470,6 +486,8 @@ public function allowStrayRequests(?array $only = null): static * * Tests only. Recording persists on the factory for the worker lifetime * and retains every subsequently completed request attempt. + * + * @return $this */ public function record(): static { @@ -653,6 +671,8 @@ public function getGlobalMiddleware(): array * * Boot-only. The connection preset and shared transport handler registry * persist on the factory for the worker lifetime. + * + * @return $this */ public function registerConnection(string $name, array $config = []): static { @@ -778,6 +798,8 @@ public function getConnectionOptions(string $name): array * * Boot-only. Reconfiguration replaces the worker-lifetime preset and * invalidates its shared transport handler for subsequent requests. + * + * @return $this */ public function setConnectionConfig(string $name, array $config): static { @@ -794,6 +816,8 @@ public function setConnectionConfig(string $name, array $config): static * * Boot or tests only. Request-time use forces subsequent requests to * rebuild warmed keep-alive, DNS, and TLS session state. + * + * @return $this */ public function forgetConnectionHandlers(): static { diff --git a/src/http/src/Client/Guzzle/CoroutineState.php b/src/http/src/Client/Guzzle/CoroutineState.php index 0f80ebde54..33de81bf6c 100644 --- a/src/http/src/Client/Guzzle/CoroutineState.php +++ b/src/http/src/Client/Guzzle/CoroutineState.php @@ -95,7 +95,7 @@ public function finish(): void try { $this->queue->run(); } catch (Throwable $exception) { - $failure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } } while ($this->promises !== [] || ! $this->queue->isEmpty()); diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index af389d9997..d7cfac4847 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -304,6 +304,8 @@ static function (Request $request, array $options, PendingRequest $pendingReques /** * Set the base URL for the pending request. + * + * @return $this */ public function baseUrl(string $url): static { @@ -316,6 +318,7 @@ public function baseUrl(string $url): static * Attach a raw body to the request. * * @param null|resource|StreamInterface|string|Stringable $content + * @return $this * * @throws InvalidArgumentException */ @@ -338,6 +341,8 @@ public function withBody(mixed $content, ?string $contentType = 'application/jso /** * Indicate the request contains JSON. + * + * @return $this */ public function asJson(): static { @@ -346,6 +351,8 @@ public function asJson(): static /** * Indicate the request contains form parameters. + * + * @return $this */ public function asForm(): static { @@ -356,6 +363,7 @@ public function asForm(): static * Attach a file to the request. * * @param resource|string $contents + * @return $this */ public function attach( array|string $name, @@ -390,6 +398,8 @@ public function attach( /** * Indicate the request is a multi-part form request. + * + * @return $this */ public function asMultipart(): static { @@ -398,6 +408,8 @@ public function asMultipart(): static /** * Specify the body format of the request. + * + * @return $this */ public function bodyFormat(string $format): static { @@ -408,6 +420,8 @@ public function bodyFormat(string $format): static /** * Set the given query parameters in the request URI. + * + * @return $this */ public function withQueryParameters(array $parameters): static { @@ -420,6 +434,8 @@ public function withQueryParameters(array $parameters): static /** * Specify the request's content type. + * + * @return $this */ public function contentType(string $contentType): static { @@ -430,6 +446,8 @@ public function contentType(string $contentType): static /** * Indicate that JSON should be returned by the server. + * + * @return $this */ public function acceptJson(): static { @@ -438,6 +456,8 @@ public function acceptJson(): static /** * Indicate the type of content that should be returned by the server. + * + * @return $this */ public function accept(string $contentType): static { @@ -446,6 +466,8 @@ public function accept(string $contentType): static /** * Add the given headers to the request. + * + * @return $this */ public function withHeaders(array $headers): static { @@ -458,6 +480,8 @@ public function withHeaders(array $headers): static /** * Add the given header to the request. + * + * @return $this */ public function withHeader(string $name, mixed $value): static { @@ -466,6 +490,8 @@ public function withHeader(string $name, mixed $value): static /** * Replace the given headers on the request. + * + * @return $this */ public function replaceHeaders(array $headers): static { @@ -476,6 +502,8 @@ public function replaceHeaders(array $headers): static /** * Specify the basic authentication username and password for the request. + * + * @return $this */ public function withBasicAuth(string $username, #[SensitiveParameter] string $password): static { @@ -486,6 +514,8 @@ public function withBasicAuth(string $username, #[SensitiveParameter] string $pa /** * Specify the digest authentication username and password for the request. + * + * @return $this */ public function withDigestAuth(string $username, #[SensitiveParameter] string $password): static { @@ -498,6 +528,8 @@ public function withDigestAuth(string $username, #[SensitiveParameter] string $p /** * Specify an authorization token for the request. + * + * @return $this */ public function withToken(#[SensitiveParameter] string $token, string $type = 'Bearer'): static { @@ -508,6 +540,8 @@ public function withToken(#[SensitiveParameter] string $token, string $type = 'B /** * Specify the user agent for the request. + * + * @return $this */ public function withUserAgent(bool|string $userAgent): static { @@ -518,6 +552,8 @@ public function withUserAgent(bool|string $userAgent): static /** * Specify the URL parameters that can be substituted into the request URL. + * + * @return $this */ public function withUrlParameters(array $parameters = []): static { @@ -529,6 +565,8 @@ public function withUrlParameters(array $parameters = []): static /** * Specify a cookie and its attributes for the request. + * + * @return $this */ public function withCookie(SetCookie $cookie): static { @@ -547,6 +585,8 @@ public function withCookie(SetCookie $cookie): static /** * Specify the cookies that should be included with the request. + * + * @return $this */ public function withCookies(array $cookies, string $domain): static { @@ -559,6 +599,8 @@ public function withCookies(array $cookies, string $domain): static /** * Specify the maximum number of redirects to allow. + * + * @return $this */ public function maxRedirects(int $max): static { @@ -575,6 +617,8 @@ public function maxRedirects(int $max): static /** * Indicate that redirects should not be followed. + * + * @return $this */ public function withoutRedirecting(): static { @@ -585,6 +629,8 @@ public function withoutRedirecting(): static /** * Indicate that TLS certificates should not be verified. + * + * @return $this */ public function withoutVerifying(): static { @@ -599,6 +645,8 @@ public function withoutVerifying(): static * The policy checks and pins every physical request, including each redirect, * to the addresses it vetted. Faked requests and clients supplied through * setClient() never reach it. + * + * @return $this */ public function withDestinationPolicy(DestinationPolicy $policy): static { @@ -642,6 +690,7 @@ public function resolveDestination(DestinationPolicy $policy, string $url): Reso * Specify the path where the body of the response should be stored. * * @param resource|StreamInterface|string $to + * @return $this */ public function sink($to): static { @@ -652,6 +701,8 @@ public function sink($to): static /** * Specify the timeout (in seconds) for the request. + * + * @return $this */ public function timeout(float|int $seconds): static { @@ -662,6 +713,8 @@ public function timeout(float|int $seconds): static /** * Specify the connect timeout (in seconds) for the request. + * + * @return $this */ public function connectTimeout(float|int $seconds): static { @@ -675,6 +728,7 @@ public function connectTimeout(float|int $seconds): static * * @param (Closure(int, Throwable): int)|int $sleepMilliseconds * @param null|(callable(null|Throwable, static, null|string): bool) $when + * @return $this */ public function retry( array|int $times, @@ -692,6 +746,8 @@ public function retry( /** * Replace the specified options on the request. + * + * @return $this */ public function withOptions(array $options): static { @@ -703,6 +759,8 @@ public function withOptions(array $options): static /** * Add new middleware the client handler stack. + * + * @return $this */ public function withMiddleware(callable $middleware): static { @@ -713,6 +771,8 @@ public function withMiddleware(callable $middleware): static /** * Prepend new middleware to the client handler stack. + * + * @return $this */ public function prependMiddleware(callable $middleware): static { @@ -723,6 +783,8 @@ public function prependMiddleware(callable $middleware): static /** * Add new request middleware the client handler stack. + * + * @return $this */ public function withRequestMiddleware(callable $middleware): static { @@ -733,6 +795,8 @@ public function withRequestMiddleware(callable $middleware): static /** * Add new response middleware the client handler stack. + * + * @return $this */ public function withResponseMiddleware(callable $middleware): static { @@ -743,6 +807,8 @@ public function withResponseMiddleware(callable $middleware): static /** * Set arbitrary attributes to store with the request. + * + * @return $this */ public function withAttributes(array $attributes): static { @@ -761,6 +827,8 @@ public function attributes(): array /** * Indicate that Telescope should not record this request. + * + * @return $this */ public function withoutTelescope(): static { @@ -771,6 +839,7 @@ public function withoutTelescope(): static * Set Telescope tags for this request. * * @param array $tags + * @return $this */ public function withTelescopeTags(array $tags): static { @@ -779,6 +848,8 @@ public function withTelescopeTags(array $tags): static /** * Indicate that OpenTelemetry should trace this request. + * + * @return $this */ public function withTrace(): static { @@ -787,6 +858,8 @@ public function withTrace(): static /** * Indicate that OpenTelemetry should not trace this request. + * + * @return $this */ public function withoutTrace(): static { @@ -797,6 +870,8 @@ public function withoutTrace(): static * Indicate that trace context should not be automatically added to this request. * * The request may still be traced locally. + * + * @return $this */ public function withoutTracePropagation(): static { @@ -805,6 +880,8 @@ public function withoutTracePropagation(): static /** * Add a new "before sending" callback to the request. + * + * @return $this */ public function beforeSending(callable $callback): static { @@ -817,6 +894,7 @@ public function beforeSending(callable $callback): static * Add a new callback to execute after the response is built. * * @param callable(Response, null|Request): mixed $callback + * @return $this */ public function afterResponse(callable $callback): static { @@ -829,6 +907,7 @@ public function afterResponse(callable $callback): static * Throw an exception if a server or client error occurs. * * @param null|(callable(Response, HttpRequestException): mixed) $callback + * @return $this */ public function throw(?callable $callback = null): static { @@ -841,6 +920,7 @@ public function throw(?callable $callback = null): static * Throw an exception if a server or client error occurred and the given condition evaluates to true. * * @param null|(callable(Response, HttpRequestException): mixed) $callback + * @return $this */ public function throwIf(bool|callable $condition, ?callable $callback = null): static { @@ -855,6 +935,7 @@ public function throwIf(bool|callable $condition, ?callable $callback = null): s * Throw an exception if a server or client error occurred and the given condition evaluates to false. * * @param null|(callable(Response, HttpRequestException): mixed) $callback + * @return $this */ public function throwUnless(bool|callable $condition, ?callable $callback = null): static { @@ -867,6 +948,8 @@ public function throwUnless(bool|callable $condition, ?callable $callback = null /** * Dump the request before sending. + * + * @return $this */ public function dump(): static { @@ -881,6 +964,8 @@ public function dump(): static /** * Dump the request before sending and end the script. + * + * @return $this */ public function dd(): static { @@ -2364,6 +2449,8 @@ protected function runAfterResponseCallbacks(Response $response): Response /** * Register a stub callable that will intercept requests and be able to return stub responses. + * + * @return $this */ public function stub(callable|Collection $callback): static { @@ -2376,6 +2463,8 @@ public function stub(callable|Collection $callback): static /** * Indicate that an exception should be thrown if any request is not faked. + * + * @return $this */ public function preventStrayRequests(bool $prevent = true): static { @@ -2386,6 +2475,8 @@ public function preventStrayRequests(bool $prevent = true): static /** * Allow stray, unfaked requests for specific URL patterns. + * + * @return $this */ public function allowStrayRequests(array $only): static { @@ -2473,6 +2564,8 @@ protected function dispatchConnectionFailedEvent(Request $request, ConnectionExc /** * Indicate that request exceptions should be truncated to the given length. + * + * @return $this */ public function truncateExceptionsAt(int $length): static { @@ -2483,6 +2576,8 @@ public function truncateExceptionsAt(int $length): static /** * Indicate that request exceptions should not be truncated. + * + * @return $this */ public function dontTruncateExceptions(): static { @@ -2542,6 +2637,8 @@ protected function marshalAsyncTransportExceptionWithResponse(Throwable $e, Resp * Set the client instance. * * The client owns its whole handler stack, so destination policies do not apply to it. + * + * @return $this */ public function setClient(ClientInterface $client): static { @@ -2554,6 +2651,8 @@ public function setClient(ClientInterface $client): static * Create a new client instance using the given handler. * * Destination-restricted requests reject custom handlers, since their pins only bind the cURL handler. + * + * @return $this */ public function setHandler(callable $handler): static { @@ -2576,6 +2675,8 @@ public function getOptions(): array /** * Set the pending request connection. + * + * @return $this */ public function connection(string $connection, ?array $config = null): static { diff --git a/src/http/src/Client/Request.php b/src/http/src/Client/Request.php index b245090fd1..2016a41326 100644 --- a/src/http/src/Client/Request.php +++ b/src/http/src/Client/Request.php @@ -258,6 +258,8 @@ protected function mediaType(): string * Set the logical data on the request. * * This does not mark the body as decoded, so a final JSON or form body may replace it. + * + * @return $this */ public function withData(array $data): static { @@ -278,6 +280,8 @@ public function query(): array /** * Add query parameters to the request. + * + * @return $this */ public function withQuery(array $query = []): static { @@ -290,6 +294,8 @@ public function withQuery(array $query = []): static /** * Remove query parameters from the request. + * + * @return $this */ public function withoutQuery(array|string $keys = []): static { @@ -312,6 +318,8 @@ public function attributes(): array /** * Set the request's attribute data. + * + * @return $this */ public function setRequestAttributes(array $attributes): static { diff --git a/src/http/src/Client/Response.php b/src/http/src/Client/Response.php index ad90119dbf..6d92faba1d 100644 --- a/src/http/src/Client/Response.php +++ b/src/http/src/Client/Response.php @@ -174,6 +174,8 @@ public function object(?int $flags = null): mixed * The callback will be invoked with the following parameters: * - string $body: The raw response body. * - bool $asObject: Whether JSON objects should be decoded as objects. + * + * @return $this */ public function decodeUsing(?Closure $callback): static { @@ -313,6 +315,8 @@ public function serverError(): bool /** * Execute the given callback if there was a server or client error. + * + * @return $this */ public function onError(callable $callback): static { @@ -341,6 +345,8 @@ public function handlerStats(): array /** * Close the stream and any underlying resources. + * + * @return $this */ public function close(): static { @@ -381,6 +387,7 @@ protected function newRequestException(): RequestException * Throw an exception if a server or client error occurred. * * @param null|(callable(Response, RequestException): mixed) $callback + * @return $this * * @throws RequestException */ @@ -401,6 +408,7 @@ public function throw(?callable $callback = null): static * Throw an exception if a server or client error occurred and the given condition evaluates to true. * * @param null|(callable(Response, RequestException): mixed) $callback + * @return $this * * @throws RequestException */ @@ -413,6 +421,7 @@ public function throwIf(bool|Closure $condition, ?callable $callback = null): st * Throw an exception if a server or client error occurred and the given condition evaluates to false. * * @param null|(callable(Response, RequestException): mixed) $callback + * @return $this * * @throws RequestException */ @@ -424,6 +433,8 @@ public function throwUnless(bool|Closure $condition, ?callable $callback = null) /** * Throw an exception if the response status code matches the given code. * + * @return $this + * * @throws RequestException */ public function throwIfStatus(int|callable $statusCode): static @@ -439,6 +450,8 @@ public function throwIfStatus(int|callable $statusCode): static /** * Throw an exception unless the response status code matches the given code. * + * @return $this + * * @throws RequestException */ public function throwUnlessStatus(int|callable $statusCode): static @@ -453,6 +466,8 @@ public function throwUnlessStatus(int|callable $statusCode): static /** * Throw an exception if the response status code is a 4xx level code. * + * @return $this + * * @throws RequestException */ public function throwIfClientError(): static @@ -463,6 +478,8 @@ public function throwIfClientError(): static /** * Throw an exception if the response status code is a 5xx level code. * + * @return $this + * * @throws RequestException */ public function throwIfServerError(): static @@ -472,6 +489,8 @@ public function throwIfServerError(): static /** * Indicate that request exceptions should be truncated to the given length. + * + * @return $this */ public function truncateExceptionsAt(int $length): static { @@ -482,6 +501,8 @@ public function truncateExceptionsAt(int $length): static /** * Indicate that request exceptions should not be truncated. + * + * @return $this */ public function dontTruncateExceptions(): static { @@ -526,6 +547,8 @@ public function dd(?string $key = null): never /** * Dump the headers from the response. + * + * @return $this */ public function dumpHeaders(): static { diff --git a/src/http/src/Client/ResponseSequence.php b/src/http/src/Client/ResponseSequence.php index e7f0006b78..66128b5431 100644 --- a/src/http/src/Client/ResponseSequence.php +++ b/src/http/src/Client/ResponseSequence.php @@ -36,6 +36,7 @@ public function __construct( * Push a response to the sequence. * * @param null|array|resource|StreamInterface|string $body + * @return $this */ public function push(mixed $body = null, int $status = 200, array $headers = []): static { @@ -46,6 +47,8 @@ public function push(mixed $body = null, int $status = 200, array $headers = []) /** * Push a response with the given status code to the sequence. + * + * @return $this */ public function pushStatus(int $status, array $headers = []): static { @@ -56,6 +59,8 @@ public function pushStatus(int $status, array $headers = []): static /** * Push a response with the contents of a file as the body to the sequence. + * + * @return $this */ public function pushFile(string $filePath, int $status = 200, array $headers = []): static { @@ -68,6 +73,8 @@ public function pushFile(string $filePath, int $status = 200, array $headers = [ /** * Push a connection exception to the sequence. + * + * @return $this */ public function pushFailedConnection(?string $message = null): static { @@ -78,6 +85,8 @@ public function pushFailedConnection(?string $message = null): static /** * Push a response to the sequence. + * + * @return $this */ public function pushResponse(mixed $response): static { @@ -88,6 +97,8 @@ public function pushResponse(mixed $response): static /** * Make the sequence return a default response when it is empty. + * + * @return $this */ public function whenEmpty(Closure|PromiseInterface $response): static { @@ -99,6 +110,8 @@ public function whenEmpty(Closure|PromiseInterface $response): static /** * Make the sequence return a default response when it is empty. + * + * @return $this */ public function dontFailWhenEmpty(): static { diff --git a/src/http/src/Concerns/InteractsWithInput.php b/src/http/src/Concerns/InteractsWithInput.php index 814bc15c70..d59f41b1a3 100644 --- a/src/http/src/Concerns/InteractsWithInput.php +++ b/src/http/src/Concerns/InteractsWithInput.php @@ -4,6 +4,7 @@ namespace Hypervel\Http\Concerns; +use Closure; use Hypervel\Http\UploadedFile; use Hypervel\Image\Image; use Hypervel\Support\Arr; @@ -11,6 +12,7 @@ use Hypervel\Support\Traits\Dumpable; use Hypervel\Support\Traits\InteractsWithData; use SplFileInfo; +use Stringable; use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; use Symfony\Component\HttpFoundation\InputBag; @@ -21,8 +23,10 @@ trait InteractsWithInput /** * Retrieve a server variable from the request. + * + * @return ($key is null ? array : mixed) */ - public function server(?string $key = null, string|array|int|float|null $default = null): string|array|int|float|null + public function server(?string $key = null, mixed $default = null): mixed { return $this->retrieveItem('server', $key, $default); } @@ -37,6 +41,10 @@ public function hasHeader(string $key): bool /** * Retrieve a header from the request. + * + * A header stored with a null value returns null even when a default is given. + * + * @return ($key is null ? array> : null|string) */ public function header(?string $key = null, string|array|null $default = null): string|array|null { @@ -48,7 +56,7 @@ public function header(?string $key = null, string|array|null $default = null): */ public function bearerToken(): ?string { - $header = $this->header('Authorization', ''); + $header = $this->header('Authorization') ?? ''; $position = strripos($header, 'Bearer '); @@ -115,16 +123,26 @@ public function fluent(array|string|null $key = null, array $default = []): Flue /** * Retrieve a query string item from the request. + * + * @template TDefault + * + * @param TDefault $default + * @return ($key is null ? array : array|bool|float|int|string|Stringable|TDefault) */ - public function query(?string $key = null, string|array|null $default = null): string|array|null + public function query(?string $key = null, mixed $default = null): mixed { return $this->retrieveItem('query', $key, $default); } /** * Retrieve a request payload item from the request. + * + * @template TDefault + * + * @param TDefault $default + * @return ($key is null ? array : array|bool|float|int|string|Stringable|TDefault) */ - public function post(?string $key = null, string|array|null $default = null): string|array|null + public function post(?string $key = null, mixed $default = null): mixed { return $this->retrieveItem('request', $key, $default); } @@ -139,8 +157,13 @@ public function hasCookie(string $key): bool /** * Retrieve a cookie from the request. + * + * @template TDefault + * + * @param TDefault $default + * @return ($key is null ? array : array|bool|float|int|string|Stringable|TDefault) */ - public function cookie(?string $key = null, string|array|null $default = null): string|array|null + public function cookie(?string $key = null, mixed $default = null): mixed { return $this->retrieveItem('cookies', $key, $default); } @@ -148,7 +171,9 @@ public function cookie(?string $key = null, string|array|null $default = null): /** * Get an array of all of the files on the request. * - * @return array + * Nested inputs give nested arrays, and an input submitted without a file is null. + * + * @return array|UploadedFile> */ public function allFiles(): array { @@ -160,13 +185,15 @@ public function allFiles(): array /** * Convert the given array of Symfony UploadedFiles to custom Hypervel UploadedFiles. * - * @param array $files - * @return array + * @template TKey of array-key + * + * @param array|SymfonyUploadedFile> $files + * @return array|UploadedFile> */ protected function convertUploadedFiles(array $files): array { return array_map(function ($file) { - if (is_null($file) || (is_array($file) && empty(array_filter($file)))) { /* @phpstan-ignore function.impossibleType, arrayFilter.same (PHP's $_FILES can produce null entries despite the docblock type) */ + if (is_null($file) || (is_array($file) && empty(array_filter($file)))) { return $file; } @@ -206,9 +233,12 @@ protected function isValidFile(mixed $file): bool /** * Retrieve a file from the request. * - * @return ($key is null ? array : null|UploadedFile|UploadedFile[]) + * @template TDefault + * + * @param (Closure(): TDefault)|TDefault $default + * @return ($key is null ? array|UploadedFile> : null|array|TDefault|UploadedFile) */ - public function file(?string $key = null, mixed $default = null): UploadedFile|array|null + public function file(?string $key = null, mixed $default = null): mixed { return data_get($this->allFiles(), $key, $default); } @@ -238,7 +268,7 @@ protected function data(?string $key = null, mixed $default = null): mixed /** * Retrieve a parameter item from a given source. */ - protected function retrieveItem(string $source, ?string $key, string|array|int|float|null $default): string|array|int|float|null + protected function retrieveItem(string $source, ?string $key, mixed $default): mixed { if (is_null($key)) { return $this->{$source}->all(); @@ -253,6 +283,8 @@ protected function retrieveItem(string $source, ?string $key, string|array|int|f /** * Dump the items. + * + * @return $this */ public function dump(mixed $keys = []): static { diff --git a/src/http/src/JsonResponse.php b/src/http/src/JsonResponse.php index 304c11e8ac..58aa08f06e 100755 --- a/src/http/src/JsonResponse.php +++ b/src/http/src/JsonResponse.php @@ -97,6 +97,8 @@ public static function fromJsonString(?string $data = null, int $status = 200, a /** * Set the JSONP callback. + * + * @return $this */ public function withCallback(?string $callback = null): static { diff --git a/src/http/src/RedirectResponse.php b/src/http/src/RedirectResponse.php index 8ffc4fbe45..24bd84f409 100755 --- a/src/http/src/RedirectResponse.php +++ b/src/http/src/RedirectResponse.php @@ -36,6 +36,8 @@ class RedirectResponse extends BaseRedirectResponse implements Responsable /** * Flash a piece of data to the session. + * + * @return $this */ public function with(string|array $key, mixed $value = null): static { @@ -50,6 +52,8 @@ public function with(string|array $key, mixed $value = null): static /** * Flash an array of input to the session. + * + * @return $this */ public function withInput(?array $input = null): static { @@ -80,6 +84,8 @@ protected function removeFilesFromInput(array $input): array /** * Flash only the specified input keys to the session. + * + * @return $this */ public function onlyInput(): static { @@ -88,6 +94,8 @@ public function onlyInput(): static /** * Flash all input except the specified keys to the session. + * + * @return $this */ public function exceptInput(): static { @@ -96,6 +104,8 @@ public function exceptInput(): static /** * Flash a container of errors to the session. + * + * @return $this */ public function withErrors(MessageProvider|array|string $provider, string $key = 'default'): static { @@ -129,6 +139,8 @@ protected function parseErrors(MessageProvider|array|string $provider): MessageB /** * Add a fragment identifier to the URL. + * + * @return $this */ public function withFragment(string $fragment): static { @@ -138,6 +150,8 @@ public function withFragment(string $fragment): static /** * Remove any fragment identifier from the response URL. + * + * @return $this */ public function withoutFragment(): static { @@ -182,6 +196,8 @@ public function getRequest(): ?Request /** * Set the request instance. + * + * @return $this */ public function setRequest(Request $request): static { @@ -200,6 +216,8 @@ public function getSession(): ?SessionStore /** * Set the session store instance. + * + * @return $this */ public function setSession(SessionStore $session): static { diff --git a/src/http/src/Request.php b/src/http/src/Request.php index abcdc46910..79b97b1d2b 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -87,7 +87,7 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess /** * All of the converted files for the request. * - * @var null|array + * @var null|array|UploadedFile> */ protected ?array $convertedFiles = null; @@ -1249,7 +1249,7 @@ public function user(?string $guard = null): mixed /** * Get the route handling the request. * - * @return ($param is null ? null|Route : null|object|string) + * @return ($param is null ? null|Route : mixed) */ public function route(?string $param = null, mixed $default = null): mixed { diff --git a/src/http/src/Resources/Json/JsonResource.php b/src/http/src/Resources/Json/JsonResource.php index c2db04c362..139cc3b425 100644 --- a/src/http/src/Resources/Json/JsonResource.php +++ b/src/http/src/Resources/Json/JsonResource.php @@ -181,6 +181,8 @@ public function with(Request $request): array /** * Add additional metadata to the resource response. + * + * @return $this */ public function additional(array $data): static { diff --git a/src/http/src/Resources/Json/ResourceCollection.php b/src/http/src/Resources/Json/ResourceCollection.php index dd36ac65ec..e811428ae8 100644 --- a/src/http/src/Resources/Json/ResourceCollection.php +++ b/src/http/src/Resources/Json/ResourceCollection.php @@ -50,6 +50,8 @@ public function __construct(mixed $resource) /** * Indicate that all current query parameters should be appended to pagination links. + * + * @return $this */ public function preserveQuery(): static { @@ -60,6 +62,8 @@ public function preserveQuery(): static /** * Specify the query string parameters that should be present on pagination links. + * + * @return $this */ public function withQuery(array $query): static { diff --git a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php index 69fc92c582..08f00a6af4 100644 --- a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php +++ b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php @@ -704,6 +704,8 @@ protected function resolveResourceMetaInformation(JsonApiRequest $request): arra /** * Indicate that attributes should respect the request's sparse fieldsets. + * + * @return $this */ public function respectFieldsAndIncludesInQueryString(bool $value = true): static { @@ -714,6 +716,8 @@ public function respectFieldsAndIncludesInQueryString(bool $value = true): stati /** * Indicate that attributes should ignore the request's sparse fieldsets. + * + * @return $this */ public function ignoreFieldsAndIncludesInQueryString(): static { @@ -722,6 +726,8 @@ public function ignoreFieldsAndIncludesInQueryString(): static /** * Indicate that relationship should include loaded relationships. + * + * @return $this */ public function includePreviouslyLoadedRelationships(): static { diff --git a/src/http/src/Testing/File.php b/src/http/src/Testing/File.php index 0e62603644..0f65fb69fb 100644 --- a/src/http/src/Testing/File.php +++ b/src/http/src/Testing/File.php @@ -75,6 +75,8 @@ public static function image(string $name, int $width = 10, int $height = 10): s /** * Set the "size" of the file in kilobytes. + * + * @return $this */ public function size(int $kilobytes): static { @@ -93,6 +95,8 @@ public function getSize(): int /** * Set the MIME type for the file. + * + * @return $this */ public function mimeType(string $mimeType): static { diff --git a/src/image/src/Image.php b/src/image/src/Image.php index b5742e7862..057d2de188 100644 --- a/src/image/src/Image.php +++ b/src/image/src/Image.php @@ -12,6 +12,7 @@ use Hypervel\Contracts\Image\Driver; use Hypervel\Contracts\Image\Transformation; use Hypervel\Contracts\Support\Responsable; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; use Hypervel\Http\Response; use Hypervel\Http\UploadedFile; @@ -31,7 +32,6 @@ use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Macroable; use Stringable; -use Swoole\Coroutine\CanceledException; use UnitEnum; class Image implements Responsable, Stringable @@ -500,9 +500,13 @@ protected function process(): string { try { return $this->resolveDriver()->process($this->source->contents(), $this->pipeline); - } catch (CanceledException|ImageException $exception) { + } catch (ImageException $exception) { throw $exception; } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + throw new ImageException("Failed to process image: {$exception->getMessage()}", 0, $exception); } } @@ -572,9 +576,11 @@ public function dimensions(): array try { return $this->dimensions = $driver->dimensions($contents); - } catch (CanceledException $exception) { - throw $exception; } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + throw new ImageException( "Unable to determine the dimensions of the image: {$exception->getMessage()}", previous: $exception, diff --git a/src/image/src/ImageManager.php b/src/image/src/ImageManager.php index bf8d073b7d..b929f8cc75 100644 --- a/src/image/src/ImageManager.php +++ b/src/image/src/ImageManager.php @@ -13,7 +13,6 @@ use Hypervel\Image\Drivers\GdDriver; use Hypervel\Image\Drivers\ImagickDriver; use Hypervel\Support\Manager; -use Hypervel\Support\Str; use InvalidArgumentException; use UnitEnum; @@ -114,9 +113,7 @@ public function fromUrl(string $url): Image */ protected function createDriver(string $driver): Driver { - $method = 'create' . Str::studly($driver) . 'Driver'; - - if (! isset($this->customCreators[$driver]) && ! method_exists($this, $method)) { + if (! isset($this->customCreators[$driver]) && $this->getCreatorMethod($driver) === null) { throw new InvalidArgumentException("Image driver [{$driver}] is not supported."); } diff --git a/src/inertia/composer.json b/src/inertia/composer.json index 0fa3755d0f..cf26ceeb37 100644 --- a/src/inertia/composer.json +++ b/src/inertia/composer.json @@ -34,7 +34,7 @@ "require": { "php": "^8.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/process": "^8.1", "hypervel/broadcasting": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/inertia/src/Middleware.php b/src/inertia/src/Middleware.php index df13157080..3714c1f28e 100644 --- a/src/inertia/src/Middleware.php +++ b/src/inertia/src/Middleware.php @@ -333,7 +333,7 @@ public function resolveValidationErrors(Request $request): object return (object) collect($bags)->map(function ($bag) { return (object) collect($bag->messages())->map(function ($errors) { return $this->withAllErrors ? $errors : $errors[0]; - })->toArray(); + })->all(); })->pipe(function ($bags) use ($request) { $errorBag = $request->header(Header::ERROR_BAG); diff --git a/src/inertia/src/Testing/TestResponseMacros.php b/src/inertia/src/Testing/TestResponseMacros.php index fb3aacd870..2622493bba 100644 --- a/src/inertia/src/Testing/TestResponseMacros.php +++ b/src/inertia/src/Testing/TestResponseMacros.php @@ -16,7 +16,7 @@ class TestResponseMacros public function assertInertia(): Closure { return function (?Closure $callback = null) { - // @phpstan-ignore argument.type + // @phpstan-ignore argument.type (the macro closure is bound to the TestResponse) $assert = AssertableInertia::fromTestResponse($this); if (is_null($callback)) { @@ -35,7 +35,7 @@ public function assertInertia(): Closure public function inertiaPage(): Closure { return function () { - // @phpstan-ignore argument.type + // @phpstan-ignore argument.type (the macro closure is bound to the TestResponse) return AssertableInertia::fromTestResponse($this)->toArray(); }; } @@ -46,7 +46,7 @@ public function inertiaPage(): Closure public function inertiaProps(): Closure { return function (?string $propName = null) { - // @phpstan-ignore argument.type + // @phpstan-ignore argument.type (the macro closure is bound to the TestResponse) $page = AssertableInertia::fromTestResponse($this)->toArray(); return Arr::get($page['props'], $propName); @@ -59,7 +59,7 @@ public function inertiaProps(): Closure public function assertInertiaFlash(): Closure { return function (string $key, mixed $expected = null) { - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (the macro closure is bound to the TestResponse) $flash = $this->session()->get(SessionKey::FLASH_DATA, []); func_num_args() > 1 @@ -76,7 +76,7 @@ public function assertInertiaFlash(): Closure public function assertInertiaFlashMissing(): Closure { return function (string $key) { - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (the macro closure is bound to the TestResponse) $flash = $this->session()->get(SessionKey::FLASH_DATA, []); AssertableInertia::assertFlashMissing($flash, $key); diff --git a/src/jwt/src/Storage/CacheStorage.php b/src/jwt/src/Storage/CacheStorage.php index 9e8f634456..1b5982f37a 100644 --- a/src/jwt/src/Storage/CacheStorage.php +++ b/src/jwt/src/Storage/CacheStorage.php @@ -85,7 +85,7 @@ public function destroy(string $key): bool */ public function flush(): bool { - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (Repository method outside the cache contract) return $this->cache->tags([$this->tag])->flush(); } @@ -94,7 +94,7 @@ public function flush(): bool */ protected function writeCache(): CacheContract { - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (Repository method outside the cache contract) return $this->writesThroughTags ? $this->cache->tags([$this->tag]) : $this->cache; } @@ -103,7 +103,7 @@ protected function writeCache(): CacheContract */ protected function readCache(): CacheContract { - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (Repository method outside the cache contract) return $this->readsThroughTags ? $this->cache->tags([$this->tag]) : $this->cache; } diff --git a/src/log/src/Context/ContextServiceProvider.php b/src/log/src/Context/ContextServiceProvider.php index e1066d6dec..8117c5b30a 100644 --- a/src/log/src/Context/ContextServiceProvider.php +++ b/src/log/src/Context/ContextServiceProvider.php @@ -37,7 +37,6 @@ public function boot(): void return []; } - /** @phpstan-ignore staticMethod.notFound */ $context = Context::dehydrate(); // IMPORTANT: Uses Laravel's payload key for cross-framework queue interoperability. @@ -51,7 +50,6 @@ public function boot(): void $context = $event->job->payload()['illuminate:log:context'] ?? null; if ($context !== null || Repository::hasInstance() || $events->hasListeners(ContextHydrated::class)) { - /* @phpstan-ignore staticMethod.notFound */ Context::hydrate($context); } }); diff --git a/src/log/src/LogManager.php b/src/log/src/LogManager.php index ce50c702d0..c473c676d6 100644 --- a/src/log/src/LogManager.php +++ b/src/log/src/LogManager.php @@ -272,16 +272,16 @@ protected function createStackDriver(array $config): LoggerInterface $handlers = (new Collection($config['channels'])) ->flatMap(function (LoggerInterface|UnitEnum|string|null $channel): array { return $channel instanceof LoggerInterface - ? $channel->getHandlers() // @phpstan-ignore method.notFound - : $this->channel($channel)->getHandlers(); // @phpstan-ignore method.notFound + ? $channel->getHandlers() // @phpstan-ignore method.notFound (Monolog method outside LoggerInterface) + : $this->channel($channel)->getHandlers(); // @phpstan-ignore method.notFound (Monolog method outside LoggerInterface) }) ->all(); $processors = (new Collection($config['channels'])) ->flatMap(function (LoggerInterface|UnitEnum|string|null $channel): array { return $channel instanceof LoggerInterface - ? $channel->getProcessors() // @phpstan-ignore method.notFound - : $this->channel($channel)->getProcessors(); // @phpstan-ignore method.notFound + ? $channel->getProcessors() // @phpstan-ignore method.notFound (Monolog method outside LoggerInterface) + : $this->channel($channel)->getProcessors(); // @phpstan-ignore method.notFound (Monolog method outside LoggerInterface) }) // Filter out the wrapped context processor from constituent channels. // Each constituent already had one pushed by get(); without filtering, diff --git a/src/mail/composer.json b/src/mail/composer.json index 5ba192a8e4..9227a71088 100644 --- a/src/mail/composer.json +++ b/src/mail/composer.json @@ -41,7 +41,7 @@ "hypervel/view": "^0.4", "league/commonmark": "^2.10.3", "psr/log": "^3.0", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/mailer": "^8.1", "symfony/mime": "^8.1", "tijsverkoyen/css-to-inline-styles": "^2.2.5" @@ -53,7 +53,7 @@ "hypervel/testing": "Required to use ordered Mailable assertions (^0.4).", "phpunit/phpunit": "Required to use Mailable assertion methods (^13.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^1.0).", - "symfony/http-client": "Required to use the Symfony API mail transports (^8.1).", + "symfony/http-client": "Required to use the Symfony API mail transports (^8.1.11).", "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^8.1).", "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^8.1)." }, diff --git a/src/mail/src/Attachment.php b/src/mail/src/Attachment.php index 05166d50d6..b9238e16e4 100644 --- a/src/mail/src/Attachment.php +++ b/src/mail/src/Attachment.php @@ -101,8 +101,7 @@ public static function fromStorageDisk(?string $disk, string $path): static $mime = $attachment->mime; if ($mime === null) { - // The contract omits adapter metadata methods, which every shipped disk provides. - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (the contract omits adapter metadata methods, which every shipped disk provides) $mime = $storage->mimeType($path); } @@ -131,6 +130,8 @@ protected static function getStorageDisk(?string $disk): Filesystem /** * Set the attached file's filename. + * + * @return $this */ public function as(?string $name): static { @@ -141,6 +142,8 @@ public function as(?string $name): static /** * Set the attached file's MIME type. + * + * @return $this */ public function withMime(string $mime): static { diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php index 8ed77ffb69..765fdcd798 100644 --- a/src/mail/src/MailManager.php +++ b/src/mail/src/MailManager.php @@ -567,7 +567,7 @@ protected function createPostmarkTransport(array $config): PostmarkApiTransport ? ['message_stream' => $config['message_stream_id']] : []; - return $factory->create(new Dsn( // @phpstan-ignore return.type + return $factory->create(new Dsn( // @phpstan-ignore return.type (the Postmark factory creates this transport for the postmark+api scheme) 'postmark+api', 'default', $config['key'], @@ -795,6 +795,8 @@ public function getApplication(): Container * Tests only. Swaps the singleton's container and configuration references * without rebuilding resolved mailers; per-request use races across * coroutines and breaks every concurrent mail send. + * + * @return $this */ public function setApplication(Container $app): static { @@ -809,6 +811,8 @@ public function setApplication(Container $app): static * * Boot or tests only. This is cache-only: pooled transports remain shared * resources until purged or reclaimed by their idle TTL. + * + * @return $this */ public function forgetMailers(): static { diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index 8ac9bcd673..818f873304 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -409,6 +409,8 @@ protected function markdownTheme(): string /** * Add the sender to the message. + * + * @return $this */ protected function buildFrom(Message $message): static { @@ -421,6 +423,8 @@ protected function buildFrom(Message $message): static /** * Add all of the recipients to the message. + * + * @return $this */ protected function buildRecipients(Message $message): static { @@ -435,6 +439,8 @@ protected function buildRecipients(Message $message): static /** * Set the subject for the message. + * + * @return $this */ protected function buildSubject(Message $message): static { @@ -449,6 +455,8 @@ protected function buildSubject(Message $message): static /** * Add all of the attachments to the message. + * + * @return $this */ protected function buildAttachments(Message $message): static { @@ -482,8 +490,7 @@ protected function buildDiskAttachments(Message $message): void $options = $attachment['options']; if (! isset($options['mime'])) { - // The contract omits adapter metadata methods, which every shipped disk provides. - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (the contract omits adapter metadata methods, which every shipped disk provides) $mime = $storage->mimeType($attachment['path']); if ($mime !== false) { @@ -501,6 +508,8 @@ protected function buildDiskAttachments(Message $message): void /** * Add all defined tags to the message. + * + * @return $this */ protected function buildTags(Message $message): static { @@ -515,6 +524,8 @@ protected function buildTags(Message $message): static /** * Add all defined metadata to the message. + * + * @return $this */ protected function buildMetadata(Message $message): static { @@ -529,6 +540,8 @@ protected function buildMetadata(Message $message): static /** * Run the callbacks for the message. + * + * @return $this */ protected function runCallbacks(Message $message): static { @@ -569,6 +582,8 @@ public function priority(int $level = 3): static /** * Set the sender of the message. + * + * @return $this */ public function from(array|object|string $address, ?string $name = null): static { @@ -643,6 +658,8 @@ public function hasBcc(array|object|string $address, ?string $name = null): bool /** * Set the "reply to" address of the message. + * + * @return $this */ public function replyTo(array|object|string $address, ?string $name = null): static { @@ -783,6 +800,8 @@ private function hasEnvelopeRecipient(string $address, ?string $name, string $pr /** * Set the subject of the message. + * + * @return $this */ public function subject(string $subject): static { @@ -802,6 +821,8 @@ public function hasSubject(string $subject): bool /** * Set the Markdown template for the message. + * + * @return $this */ public function markdown(string $view, array $data = []): static { @@ -813,6 +834,8 @@ public function markdown(string $view, array $data = []): static /** * Set the view and view data for the message. + * + * @return $this */ public function view(string $view, array $data = []): static { @@ -824,6 +847,8 @@ public function view(string $view, array $data = []): static /** * Set the rendered HTML content for the message. + * + * @return $this */ public function html(string $html): static { @@ -834,6 +859,8 @@ public function html(string $html): static /** * Set the plain text view for the message. + * + * @return $this */ public function text(string $textView, array $data = []): static { @@ -845,6 +872,8 @@ public function text(string $textView, array $data = []): static /** * Set the view data for the message. + * + * @return $this */ public function with(array|string $key, mixed $value = null): static { @@ -859,6 +888,8 @@ public function with(array|string $key, mixed $value = null): static /** * Attach a file to the message. + * + * @return $this */ public function attach(Attachable|Attachment|string $file, array $options = []): static { @@ -880,6 +911,8 @@ public function attach(Attachable|Attachment|string $file, array $options = []): /** * Attach multiple files to the message. + * + * @return $this */ public function attachMany(array $files): static { @@ -948,6 +981,8 @@ private function hasDeclaredAttachment(Attachment $attachment, array $options = /** * Attach a file to the message from storage. + * + * @return $this */ public function attachFromStorage(string $path, ?string $name = null, array $options = []): static { @@ -956,6 +991,8 @@ public function attachFromStorage(string $path, ?string $name = null, array $opt /** * Attach a file to the message from storage. + * + * @return $this */ public function attachFromStorageDisk(?string $disk, string $path, ?string $name = null, array $options = []): static { @@ -994,6 +1031,8 @@ public function hasAttachmentFromStorageDisk(?string $disk, string $path, ?strin /** * Attach in-memory data as an attachment. + * + * @return $this */ public function attachData(string $data, string $name, array $options = []): static { @@ -1019,6 +1058,8 @@ public function hasAttachedData(string $data, ?string $name, array $options = [] /** * Add a tag header to the message when supported by the underlying transport. + * + * @return $this */ public function tag(string $value): static { @@ -1038,6 +1079,8 @@ public function hasTag(string $value): bool /** * Add a metadata header to the message when supported by the underlying transport. + * + * @return $this */ public function metadata(array|string $key, int|string|null $value = null): static { @@ -1061,6 +1104,8 @@ public function hasMetadata(string $key, int|string $value): bool /** * Assert that the mailable is from the given address. + * + * @return $this */ public function assertFrom(array|object|string $address, ?string $name = null): static { @@ -1079,6 +1124,8 @@ public function assertFrom(array|object|string $address, ?string $name = null): /** * Assert that the mailable has the given recipient. + * + * @return $this */ public function assertTo(array|object|string $address, ?string $name = null): static { @@ -1097,6 +1144,8 @@ public function assertTo(array|object|string $address, ?string $name = null): st /** * Assert that the mailable has the given recipient. + * + * @return $this */ public function assertHasTo(array|object|string $address, ?string $name = null): static { @@ -1105,6 +1154,8 @@ public function assertHasTo(array|object|string $address, ?string $name = null): /** * Assert that the mailable has the given recipient. + * + * @return $this */ public function assertHasCc(array|object|string $address, ?string $name = null): static { @@ -1123,6 +1174,8 @@ public function assertHasCc(array|object|string $address, ?string $name = null): /** * Assert that the mailable has the given recipient. + * + * @return $this */ public function assertHasBcc(array|object|string $address, ?string $name = null): static { @@ -1141,6 +1194,8 @@ public function assertHasBcc(array|object|string $address, ?string $name = null) /** * Assert that the mailable has the given "reply to" address. + * + * @return $this */ public function assertHasReplyTo(array|object|string $address, ?string $name = null): static { @@ -1194,6 +1249,8 @@ private function formatActualRecipients(array $recipients): string /** * Assert that the mailable has the given subject. + * + * @return $this */ public function assertHasSubject(string $subject): static { @@ -1212,6 +1269,8 @@ public function assertHasSubject(string $subject): static /** * Assert that the given text is present in the HTML email body. + * + * @return $this */ public function assertSeeInHtml(string $string, bool $escape = true): static { @@ -1230,6 +1289,8 @@ public function assertSeeInHtml(string $string, bool $escape = true): static /** * Assert that the given text is not present in the HTML email body. + * + * @return $this */ public function assertDontSeeInHtml(string $string, bool $escape = true): static { @@ -1250,6 +1311,7 @@ public function assertDontSeeInHtml(string $string, bool $escape = true): static * Assert that the given text strings are present in order in the HTML email body. * * @param list $strings + * @return $this */ public function assertSeeInOrderInHtml(array $strings, bool $escape = true): static { @@ -1266,6 +1328,8 @@ public function assertSeeInOrderInHtml(array $strings, bool $escape = true): sta /** * Assert that the given text is present in the plain-text email body. + * + * @return $this */ public function assertSeeInText(string $string): static { @@ -1282,6 +1346,8 @@ public function assertSeeInText(string $string): static /** * Assert that the given text is not present in the plain-text email body. + * + * @return $this */ public function assertDontSeeInText(string $string): static { @@ -1300,6 +1366,7 @@ public function assertDontSeeInText(string $string): static * Assert that the given text strings are present in order in the plain-text email body. * * @param list $strings + * @return $this */ public function assertSeeInOrderInText(array $strings): static { @@ -1312,6 +1379,8 @@ public function assertSeeInOrderInText(array $strings): static /** * Assert the mailable has no attachments. + * + * @return $this */ public function assertHasNoAttachments(): static { @@ -1352,6 +1421,8 @@ public function assertHasAttachment(Attachable|Attachment|string $file, array $o /** * Assert the mailable has the given data as an attachment. + * + * @return $this */ public function assertHasAttachedData(string $data, string $name, array $options = []): static { @@ -1367,6 +1438,8 @@ public function assertHasAttachedData(string $data, string $name, array $options /** * Assert the mailable has the given attachment from storage. + * + * @return $this */ public function assertHasAttachmentFromStorage(string $path, ?string $name = null, array $options = []): static { @@ -1382,6 +1455,8 @@ public function assertHasAttachmentFromStorage(string $path, ?string $name = nul /** * Assert the mailable has the given attachment from a specific storage disk. + * + * @return $this */ public function assertHasAttachmentFromStorageDisk(string $disk, string $path, ?string $name = null, array $options = []): static { @@ -1397,6 +1472,8 @@ public function assertHasAttachmentFromStorageDisk(string $disk, string $path, ? /** * Assert that the mailable has the given tag. + * + * @return $this */ public function assertHasTag(string $tag): static { @@ -1414,6 +1491,8 @@ public function assertHasTag(string $tag): static /** * Assert that the mailable has the given metadata. + * + * @return $this */ public function assertHasMetadata(string $key, int|string $value): static { @@ -1625,6 +1704,8 @@ public function mailer(?string $mailer): static /** * Register a callback to be called with the Symfony message instance. + * + * @return $this */ public function withSymfonyMessage(callable $callback): static { diff --git a/src/mail/src/Mailables/Content.php b/src/mail/src/Mailables/Content.php index 5d36321257..f120b1574b 100644 --- a/src/mail/src/Mailables/Content.php +++ b/src/mail/src/Mailables/Content.php @@ -32,6 +32,8 @@ public function __construct( /** * Set the view for the message. + * + * @return $this */ public function view(string $view): static { @@ -42,6 +44,8 @@ public function view(string $view): static /** * Set the view for the message. + * + * @return $this */ public function html(string $view): static { @@ -50,6 +54,8 @@ public function html(string $view): static /** * Set the plain text view for the message. + * + * @return $this */ public function text(string $view): static { @@ -60,6 +66,8 @@ public function text(string $view): static /** * Set the Markdown view for the message. + * + * @return $this */ public function markdown(string $view): static { @@ -70,6 +78,8 @@ public function markdown(string $view): static /** * Set the pre-rendered HTML for the message. + * + * @return $this */ public function htmlString(string $html): static { @@ -80,6 +90,8 @@ public function htmlString(string $html): static /** * Add a piece of view data to the message. + * + * @return $this */ public function with(array|string $key, mixed $value = null): static { diff --git a/src/mail/src/Mailables/Envelope.php b/src/mail/src/Mailables/Envelope.php index a4c2e24c2c..b470ebd892 100644 --- a/src/mail/src/Mailables/Envelope.php +++ b/src/mail/src/Mailables/Envelope.php @@ -96,6 +96,8 @@ protected function normalizeAddresses(array $addresses): array /** * Specify who the message will be "from". + * + * @return $this */ public function from(Address|string $address, ?string $name = null): static { @@ -106,6 +108,8 @@ public function from(Address|string $address, ?string $name = null): static /** * Add a "to" recipient to the message envelope. + * + * @return $this */ public function to(Address|array|string $address, ?string $name = null): static { @@ -118,6 +122,8 @@ public function to(Address|array|string $address, ?string $name = null): static /** * Add a "cc" recipient to the message envelope. + * + * @return $this */ public function cc(Address|array|string $address, ?string $name = null): static { @@ -130,6 +136,8 @@ public function cc(Address|array|string $address, ?string $name = null): static /** * Add a "bcc" recipient to the message envelope. + * + * @return $this */ public function bcc(Address|array|string $address, ?string $name = null): static { @@ -142,6 +150,8 @@ public function bcc(Address|array|string $address, ?string $name = null): static /** * Add a "reply to" recipient to the message envelope. + * + * @return $this */ public function replyTo(Address|array|string $address, ?string $name = null): static { @@ -154,6 +164,8 @@ public function replyTo(Address|array|string $address, ?string $name = null): st /** * Set the subject of the message. + * + * @return $this */ public function subject(string $subject): static { @@ -164,6 +176,8 @@ public function subject(string $subject): static /** * Add "tags" to the message. + * + * @return $this */ public function tags(array $tags): static { @@ -174,6 +188,8 @@ public function tags(array $tags): static /** * Add a "tag" to the message. + * + * @return $this */ public function tag(string $tag): static { @@ -184,6 +200,8 @@ public function tag(string $tag): static /** * Add metadata to the message. + * + * @return $this */ public function metadata(string $key, int|string $value): static { @@ -194,6 +212,8 @@ public function metadata(string $key, int|string $value): static /** * Add a Symfony Message customization callback to the message. + * + * @return $this */ public function using(Closure $callback): static { diff --git a/src/mail/src/Mailables/Headers.php b/src/mail/src/Mailables/Headers.php index a9b811919a..8cd09d3f95 100644 --- a/src/mail/src/Mailables/Headers.php +++ b/src/mail/src/Mailables/Headers.php @@ -28,6 +28,8 @@ public function __construct( /** * Set the message ID. + * + * @return $this */ public function messageId(string $messageId): static { @@ -38,6 +40,8 @@ public function messageId(string $messageId): static /** * Set the message IDs referenced by this message. + * + * @return $this */ public function references(array $references): static { @@ -48,6 +52,8 @@ public function references(array $references): static /** * Set the headers for this message. + * + * @return $this */ public function text(array $text): static { diff --git a/src/mail/src/Mailer.php b/src/mail/src/Mailer.php index b1a2ee2b6f..2e8d435068 100644 --- a/src/mail/src/Mailer.php +++ b/src/mail/src/Mailer.php @@ -385,7 +385,7 @@ public function queue(array|MailableContract|string $view, UnitEnum|string|null if ($queue !== null) { // Queueable owns identifier normalization, so it is intentionally absent from the Mailable contract. - $view->onQueue($queue); // @phpstan-ignore method.notFound + $view->onQueue($queue); // @phpstan-ignore method.notFound (Queueable method outside the Mailable contract) } return $view->mailer($this->name)->queue($this->queue); @@ -422,7 +422,7 @@ public function later(DateInterval|DateTimeInterface|int $delay, array|MailableC if ($queue !== null) { // Queueable owns identifier normalization, so it is intentionally absent from the Mailable contract. - $view->onQueue($queue); // @phpstan-ignore method.notFound + $view->onQueue($queue); // @phpstan-ignore method.notFound (Queueable method outside the Mailable contract) } return $view->mailer($this->name)->later($delay, $this->queue); @@ -540,6 +540,8 @@ public function setSymfonyTransport(TransportInterface $transport): void * * Boot-only. Replaces the queue factory on the shared Mailer; per-request * use races across coroutines. + * + * @return $this */ public function setQueue(QueueFactory $queue): static { diff --git a/src/mail/src/Markdown.php b/src/mail/src/Markdown.php index a4c162746d..1ce8ec1063 100644 --- a/src/mail/src/Markdown.php +++ b/src/mail/src/Markdown.php @@ -91,7 +91,7 @@ public function render(string $view, array $data = [], mixed $inliner = null, ?s $viewFactory = $this->viewFactoryWithMailNamespace($this->htmlComponentPaths()); $bladeCompiler = $viewFactory - ->getEngineResolver() // @phpstan-ignore method.notFound + ->getEngineResolver() // @phpstan-ignore method.notFound (view Factory method outside the contract) ->resolve('blade') ->getCompiler(); @@ -251,6 +251,8 @@ public function loadComponentsFrom(array $paths = []): void * * Boot-only. Mutates the shared Markdown renderer's default theme; pass a * per-render theme to render() for message-specific themes. + * + * @return $this */ public function theme(string $theme): static { diff --git a/src/mail/src/Message.php b/src/mail/src/Message.php index 43a03669ef..b082821429 100644 --- a/src/mail/src/Message.php +++ b/src/mail/src/Message.php @@ -31,6 +31,8 @@ public function __construct( /** * Add a "from" address to the message. + * + * @return $this */ public function from(array|string $address, ?string $name = null): static { @@ -43,6 +45,8 @@ public function from(array|string $address, ?string $name = null): static /** * Set the "sender" of the message. + * + * @return $this */ public function sender(array|string $address, ?string $name = null): static { @@ -55,6 +59,8 @@ public function sender(array|string $address, ?string $name = null): static /** * Set the "return path" of the message. + * + * @return $this */ public function returnPath(Address|string $address): static { @@ -67,6 +73,8 @@ public function returnPath(Address|string $address): static /** * Add a recipient to the message. + * + * @return $this */ public function to(array|string $address, ?string $name = null, bool $override = false): static { @@ -83,6 +91,8 @@ public function to(array|string $address, ?string $name = null, bool $override = /** * Remove all "to" addresses from the message. + * + * @return $this */ public function forgetTo(): static { @@ -98,6 +108,8 @@ public function forgetTo(): static /** * Add a carbon copy to the message. + * + * @return $this */ public function cc(array|string $address, ?string $name = null, bool $override = false): static { @@ -114,6 +126,8 @@ public function cc(array|string $address, ?string $name = null, bool $override = /** * Remove all carbon copy addresses from the message. + * + * @return $this */ public function forgetCc(): static { @@ -129,6 +143,8 @@ public function forgetCc(): static /** * Add a blind carbon copy to the message. + * + * @return $this */ public function bcc(array|string $address, ?string $name = null, bool $override = false): static { @@ -145,6 +161,8 @@ public function bcc(array|string $address, ?string $name = null, bool $override /** * Remove all of the blind carbon copy addresses from the message. + * + * @return $this */ public function forgetBcc(): static { @@ -160,6 +178,8 @@ public function forgetBcc(): static /** * Add a "reply to" address to the message. + * + * @return $this */ public function replyTo(array|string $address, ?string $name = null): static { @@ -168,6 +188,8 @@ public function replyTo(array|string $address, ?string $name = null): static /** * Add a recipient to the message. + * + * @return $this */ protected function addAddresses(array|string $address, ?string $name, string $type): static { @@ -237,6 +259,7 @@ protected function ensureAddressesAreSafe(array $addresses): array * Add an address debug header for a list of recipients. * * @param Address[] $addresses + * @return $this */ protected function addAddressDebugHeader(string $header, array $addresses): static { @@ -250,6 +273,8 @@ protected function addAddressDebugHeader(string $header, array $addresses): stat /** * Set the subject of the message. + * + * @return $this */ public function subject(string $subject): static { @@ -260,6 +285,8 @@ public function subject(string $subject): static /** * Set the message priority level. + * + * @return $this */ public function priority(int $level): static { @@ -270,6 +297,8 @@ public function priority(int $level): static /** * Attach a file to the message. + * + * @return $this */ public function attach(Attachable|Attachment|string $file, array $options = []): static { @@ -290,6 +319,7 @@ public function attach(Attachable|Attachment|string $file, array $options = []): * Attach in-memory data as an attachment. * * @param resource|string $data + * @return $this */ public function attachData(mixed $data, string $name, array $options = []): static { diff --git a/src/mail/src/PendingMail.php b/src/mail/src/PendingMail.php index 3790a902b5..d14697263b 100644 --- a/src/mail/src/PendingMail.php +++ b/src/mail/src/PendingMail.php @@ -45,6 +45,8 @@ public function __construct( /** * Set the locale of the message. + * + * @return $this */ public function locale(?string $locale): static { @@ -55,6 +57,8 @@ public function locale(?string $locale): static /** * Set the recipients of the message. + * + * @return $this */ public function to(mixed $users): static { @@ -69,6 +73,8 @@ public function to(mixed $users): static /** * Set the recipients of the message. + * + * @return $this */ public function cc(mixed $users): static { @@ -79,6 +85,8 @@ public function cc(mixed $users): static /** * Set the recipients of the message. + * + * @return $this */ public function bcc(mixed $users): static { @@ -108,7 +116,7 @@ public function sendNow(MailableContract $mailable): ?SentMessage */ public function queue(MailableContract $mailable): mixed { - return $this->mailer->queue( // @phpstan-ignore method.notFound + return $this->mailer->queue( // @phpstan-ignore method.notFound (MailQueue contract method, which the framework Mailer implements) $this->fill($mailable) ); } @@ -118,7 +126,7 @@ public function queue(MailableContract $mailable): mixed */ public function later(DateInterval|DateTimeInterface|int $delay, MailableContract $mailable): mixed { - return $this->mailer->later( // @phpstan-ignore method.notFound + return $this->mailer->later( // @phpstan-ignore method.notFound (MailQueue contract method, which the framework Mailer implements) $delay, $this->fill($mailable) ); diff --git a/src/mail/src/SentMessage.php b/src/mail/src/SentMessage.php index 5b6d454741..6c222b9b1f 100644 --- a/src/mail/src/SentMessage.php +++ b/src/mail/src/SentMessage.php @@ -44,7 +44,7 @@ public function __call(string $method, array $parameters): mixed */ public function __serialize(): array { - $hasAttachments = (new Collection($this->sentMessage->getOriginalMessage()->getAttachments()))->isNotEmpty(); // @phpstan-ignore method.notFound + $hasAttachments = (new Collection($this->sentMessage->getOriginalMessage()->getAttachments()))->isNotEmpty(); // @phpstan-ignore method.notFound (the mailer always sends a Symfony Email) return [ 'hasAttachments' => $hasAttachments, diff --git a/src/mail/src/Transport/ResendTransport.php b/src/mail/src/Transport/ResendTransport.php index 1d58dd1715..77429af718 100644 --- a/src/mail/src/Transport/ResendTransport.php +++ b/src/mail/src/Transport/ResendTransport.php @@ -106,7 +106,7 @@ protected function doSend(SentMessage $message): void } try { - $result = $this->resend->emails->send([ // @phpstan-ignore property.notFound + $result = $this->resend->emails->send([ // @phpstan-ignore property.notFound (Resend's client resolves services through __get) 'from' => $envelope->getSender()->toString(), 'to' => $this->stringifyAddresses($this->getRecipients($email, $envelope)), 'cc' => $this->stringifyAddresses($email->getCc()), diff --git a/src/mail/src/Transport/SesV2Transport.php b/src/mail/src/Transport/SesV2Transport.php index fd51d649de..ad69392d24 100644 --- a/src/mail/src/Transport/SesV2Transport.php +++ b/src/mail/src/Transport/SesV2Transport.php @@ -62,7 +62,7 @@ protected function doSend(SentMessage $message): void 'ToAddresses' => (new Collection($message->getEnvelope()->getRecipients())) ->map ->toString() - ->values() // @phpstan-ignore method.nonObject (HigherOrderProxy: ->map->toString() returns Collection, not string) + ->values() ->all(), ], 'Content' => [ diff --git a/src/mcp/composer.json b/src/mcp/composer.json index b304de5ef5..e99409b93f 100644 --- a/src/mcp/composer.json +++ b/src/mcp/composer.json @@ -55,10 +55,10 @@ "league/flysystem": "^3.25.1", "psr/http-message": "^2.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/process": "^8.1", - "symfony/routing": "^8.1" + "symfony/routing": "^8.1.11" }, "suggest": { "hypervel/passport": "Required to authorize MCP clients using OAuth (^0.4).", diff --git a/src/mcp/src/Console/Commands/InspectorCommand.php b/src/mcp/src/Console/Commands/InspectorCommand.php index 5ee666d032..79ee801d89 100644 --- a/src/mcp/src/Console/Commands/InspectorCommand.php +++ b/src/mcp/src/Console/Commands/InspectorCommand.php @@ -6,13 +6,13 @@ use Exception; use Hypervel\Console\Command; +use Hypervel\Coroutine\Coroutine; use Hypervel\Mcp\Server\Registrar; use Hypervel\Routing\Exceptions\UrlGenerationException; use Hypervel\Routing\Route; use Hypervel\Routing\UrlGenerator; use Hypervel\Support\Arr; use Hypervel\Support\Facades\Process; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -160,9 +160,11 @@ public function handle(Registrar $registrar): int Process::env($env)->forever()->run($command, function (string $type, string $buffer): void { echo $buffer; })->throw(); - } catch (CanceledException $exception) { - throw $exception; } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->components->error('Failed to start MCP Inspector: ' . $exception->getMessage()); return static::FAILURE; diff --git a/src/mcp/src/Response.php b/src/mcp/src/Response.php index 414f6be451..04d991cfe7 100644 --- a/src/mcp/src/Response.php +++ b/src/mcp/src/Response.php @@ -242,8 +242,7 @@ public static function fromStorage(string $path, ?string $disk = null, ?string $ throw new InvalidArgumentException("File not found at path [{$path}]."); } - // The contract omits adapter metadata methods, which every shipped disk provides. - // @phpstan-ignore method.notFound + // @phpstan-ignore method.notFound (the contract omits adapter metadata methods, which every shipped disk provides) $mimeType ??= $storage->mimeType($path) ?: throw new InvalidArgumentException( "Unable to determine MIME type for [{$path}].", ); diff --git a/src/mcp/src/Server/Concerns/HasAnnotations.php b/src/mcp/src/Server/Concerns/HasAnnotations.php index 678413c4c1..b0d9125d24 100644 --- a/src/mcp/src/Server/Concerns/HasAnnotations.php +++ b/src/mcp/src/Server/Concerns/HasAnnotations.php @@ -27,7 +27,7 @@ public function annotations(): array $this->validateAnnotationUsage($attribute); - $annotations[$attribute->key()] = $attribute->value; // @phpstan-ignore property.notFound + $annotations[$attribute->key()] = $attribute->value; // @phpstan-ignore property.notFound (annotation attributes expose a public value property) } return $annotations; diff --git a/src/mcp/src/Server/Concerns/ReadsAttributes.php b/src/mcp/src/Server/Concerns/ReadsAttributes.php index 5a3eba728b..a6433cbe53 100644 --- a/src/mcp/src/Server/Concerns/ReadsAttributes.php +++ b/src/mcp/src/Server/Concerns/ReadsAttributes.php @@ -26,7 +26,10 @@ protected function resolveAttribute(string $attributeClass): mixed $cacheKey = static::class . '@' . $attributeClass; if (array_key_exists($cacheKey, static::$attributeCache)) { - return static::$attributeCache[$cacheKey]; // @phpstan-ignore return.type + /** @var null|T $attribute */ + $attribute = static::$attributeCache[$cacheKey]; + + return $attribute; } $reflection = new ReflectionClass($this); @@ -55,7 +58,10 @@ protected function resolveAttributes(string $attributeClass): array $cacheKey = static::class . '@' . $attributeClass . '[]'; if (array_key_exists($cacheKey, static::$attributeCache)) { - return static::$attributeCache[$cacheKey]; // @phpstan-ignore return.type + /** @var list $instances */ + $instances = static::$attributeCache[$cacheKey]; + + return $instances; } $instances = []; @@ -67,7 +73,7 @@ protected function resolveAttributes(string $attributeClass): array } } while ($reflection = $reflection->getParentClass()); - return static::$attributeCache[$cacheKey] = $instances; // @phpstan-ignore return.type + return static::$attributeCache[$cacheKey] = $instances; } /** diff --git a/src/mcp/src/Server/Contracts/Transport.php b/src/mcp/src/Server/Contracts/Transport.php index 6de267637d..0c9b130e42 100644 --- a/src/mcp/src/Server/Contracts/Transport.php +++ b/src/mcp/src/Server/Contracts/Transport.php @@ -15,8 +15,10 @@ public function onReceive(Closure $handler): void; /** * Run the transport. + * + * Implementations return a response or nothing, which no native return type can cover. */ - public function run(); // @phpstan-ignore-line + public function run(); /** * Send an encoded protocol message. diff --git a/src/mcp/src/Server/Http/Controllers/OAuthRegisterController.php b/src/mcp/src/Server/Http/Controllers/OAuthRegisterController.php index db067bec4a..183b966cd6 100644 --- a/src/mcp/src/Server/Http/Controllers/OAuthRegisterController.php +++ b/src/mcp/src/Server/Http/Controllers/OAuthRegisterController.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Container\Container; use Hypervel\Contracts\Container\BindingResolutionException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Mcp\Server\Registrar; @@ -17,7 +18,6 @@ use Hypervel\Support\Facades\Validator; use Hypervel\Support\Str; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class OAuthRegisterController @@ -104,9 +104,11 @@ public function __invoke(Request $request): JsonResponse return [$client, $this->persistClientMetadata($client, $validated)]; }); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + report($throwable); return response()->json([ diff --git a/src/mcp/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/mcp/src/Server/Methods/Concerns/InteractsWithResponses.php index e14781b664..7c249a17d1 100644 --- a/src/mcp/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/mcp/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -37,7 +37,7 @@ protected function toJsonRpcResponse(JsonRpcRequest $request, Response|ResponseF $responseFactory->responses()->each(function (Response $response) use ($request): void { if (! $this instanceof Errable && $response->isError()) { throw new JsonRpcException( - $response->content()->__toString(), // @phpstan-ignore-line + $response->content()->__toString(), -32603, $request->id, ); diff --git a/src/mcp/src/Server/Methods/GetPrompt.php b/src/mcp/src/Server/Methods/GetPrompt.php index 1bd926caa3..f5baa8c3ae 100644 --- a/src/mcp/src/Server/Methods/GetPrompt.php +++ b/src/mcp/src/Server/Methods/GetPrompt.php @@ -42,7 +42,6 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat throw new JsonRpcException($invalidArgumentException->getMessage(), -32602, $request->id); } - // @phpstan-ignore-next-line $response = $this->callHandler(fn (): mixed => Container::getInstance()->call([$prompt, 'handle']), $request); return is_iterable($response) diff --git a/src/mcp/src/Server/Methods/ReadResource.php b/src/mcp/src/Server/Methods/ReadResource.php index 138ec8b752..99cdb016ce 100644 --- a/src/mcp/src/Server/Methods/ReadResource.php +++ b/src/mcp/src/Server/Methods/ReadResource.php @@ -89,7 +89,6 @@ protected function invokeResource(Resource $resource, string $uri): mixed CoroutineContext::set(RequestContext::LIBRARY_SCRIPTS_CONTEXT_KEY, $resource->libraryScripts()); } - // @phpstan-ignore-next-line return Container::getInstance()->call([$resource, 'handle']); } diff --git a/src/mcp/src/Server/Prompt.php b/src/mcp/src/Server/Prompt.php index f0bdd8e847..14cb88d554 100644 --- a/src/mcp/src/Server/Prompt.php +++ b/src/mcp/src/Server/Prompt.php @@ -42,7 +42,7 @@ public function toMethodCall(): array */ public function toArray(): array { - // @phpstan-ignore return.type + // @phpstan-ignore return.type (PHPStan reduces mergeMeta()'s array{_meta?: …}&T return to the added key) return $this->mergeMeta($this->mergeIcons([ 'name' => $this->name(), 'title' => $this->title(), diff --git a/src/mcp/src/Server/Resource.php b/src/mcp/src/Server/Resource.php index 4e4abfd053..86763ef361 100644 --- a/src/mcp/src/Server/Resource.php +++ b/src/mcp/src/Server/Resource.php @@ -100,7 +100,7 @@ public function toArray(): array $data['uri'] = $this->uri(); } - // @phpstan-ignore return.type + // @phpstan-ignore return.type (PHPStan reduces mergeMeta()'s array{_meta?: …}&T return to the added key) return $this->mergeMeta($this->mergeIcons($data)); } diff --git a/src/mcp/src/Server/Testing/TestListResponse.php b/src/mcp/src/Server/Testing/TestListResponse.php index b083918568..427df2a32c 100644 --- a/src/mcp/src/Server/Testing/TestListResponse.php +++ b/src/mcp/src/Server/Testing/TestListResponse.php @@ -42,7 +42,7 @@ public function assertRegistered(array|string $classes): static Assert::assertContains($name, $itemNames, "The expected class [{$class}] was not found in the response content."); } - // @phpstan-ignore-next-line + // @phpstan-ignore staticMethod.alreadyNarrowedType (counts a passed assertion) Assert::assertTrue(true); return $this; @@ -64,7 +64,7 @@ public function assertNotRegistered(array|string $classes): static Assert::assertNotContains($name, $itemNames, "The expected class [{$class}] was found in the response content."); } - // @phpstan-ignore-next-line + // @phpstan-ignore staticMethod.alreadyNarrowedType (counts a passed assertion) Assert::assertTrue(true); return $this; diff --git a/src/mcp/src/Server/Testing/TestResponse.php b/src/mcp/src/Server/Testing/TestResponse.php index 3ef744d8bf..e80d263df7 100644 --- a/src/mcp/src/Server/Testing/TestResponse.php +++ b/src/mcp/src/Server/Testing/TestResponse.php @@ -81,11 +81,11 @@ public function assertSee(array|string $text): static } } - // @phpstan-ignore-next-line + // @phpstan-ignore staticMethod.impossibleType (fails with the message, as upstream) Assert::assertTrue(false, "The expected text [{$segment}] was not found in the response content."); } - // @phpstan-ignore-next-line + // @phpstan-ignore staticMethod.alreadyNarrowedType (counts a passed assertion) Assert::assertTrue(true); return $this; @@ -106,7 +106,7 @@ public function assertDontSee(array|string $text): static foreach (is_array($text) ? $text : [$text] as $segment) { foreach ($seeable as $message) { if (str_contains($message, $segment)) { - // @phpstan-ignore-next-line + // @phpstan-ignore staticMethod.impossibleType (fails with the message, as upstream) Assert::assertTrue(false, "The unexpected text [{$segment}] was found in the response content."); return $this; @@ -114,7 +114,7 @@ public function assertDontSee(array|string $text): static } } - // @phpstan-ignore-next-line + // @phpstan-ignore staticMethod.alreadyNarrowedType (counts a passed assertion) Assert::assertTrue(true); return $this; @@ -403,7 +403,7 @@ public function assertSentNotification(string $method, ?array $params = null): s $content = $notification->toArray(); if ($content['method'] === $method && (is_array($params) === false || $this->toJsonRepresentation($content['params']) === $this->toJsonRepresentation($params))) { - Assert::assertTrue(true); // @phpstan-ignore-line + Assert::assertTrue(true); // @phpstan-ignore staticMethod.alreadyNarrowedType (counts a passed assertion) return $this; } @@ -654,14 +654,11 @@ public function ddErrors(): void protected function content(): array { return (match (true) { - // @phpstan-ignore-next-line $this->primitive instanceof Tool => collect($this->response->toArray()['result']['content'] ?? []) ->map(fn (array $message): string => $message['text'] ?? $message['data'] ?? ''), - // @phpstan-ignore-next-line $this->primitive instanceof Prompt => collect($this->response->toArray()['result']['messages'] ?? []) ->map(fn (array $message): array => $message['content']) ->map(fn (array $content): string => $content['text'] ?? $content['data'] ?? ''), - // @phpstan-ignore-next-line $this->primitive instanceof Resource => collect($this->response->toArray()['result']['contents'] ?? []) ->map(fn (array $item): string => $item['text'] ?? $item['blob'] ?? ''), default => throw new RuntimeException('This primitive type is not supported.'), diff --git a/src/mcp/src/Server/Tool.php b/src/mcp/src/Server/Tool.php index a8dcf25ce2..e66e72a8a0 100644 --- a/src/mcp/src/Server/Tool.php +++ b/src/mcp/src/Server/Tool.php @@ -103,7 +103,7 @@ public function toArray(): array ]; } - // @phpstan-ignore return.type + // @phpstan-ignore return.type (PHPStan reduces mergeMeta()'s array{_meta?: …}&T return to the added key) return $result; } diff --git a/src/mcp/src/Server/ToolInvoker.php b/src/mcp/src/Server/ToolInvoker.php index da62b21f1d..c985b22545 100644 --- a/src/mcp/src/Server/ToolInvoker.php +++ b/src/mcp/src/Server/ToolInvoker.php @@ -33,7 +33,6 @@ public function __construct(protected bool $mirrorStructuredContent = true) */ public function invoke(Tool $tool, JsonRpcRequest $request): Generator|JsonRpcResponse { - // @phpstan-ignore-next-line $response = $this->callHandler(fn (): mixed => Container::getInstance()->call([$tool, 'handle']), $request); return is_iterable($response) diff --git a/src/nested-set/src/Eloquent/AncestorsRelation.php b/src/nested-set/src/Eloquent/AncestorsRelation.php index be63ef9d04..c657b6205c 100644 --- a/src/nested-set/src/Eloquent/AncestorsRelation.php +++ b/src/nested-set/src/Eloquent/AncestorsRelation.php @@ -59,15 +59,15 @@ protected function constrainEagerModels(Builder $query, array $models): void } $grammar = $query->getGrammar(); - $lft = $grammar->wrap($this->related->qualifyColumn($this->related->getLftName())); /* @phpstan-ignore method.notFound */ - $rgt = $grammar->wrap($this->related->qualifyColumn($this->related->getRgtName())); /* @phpstan-ignore method.notFound */ + $lft = $grammar->wrap($this->related->qualifyColumn($this->related->getLftName())); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $rgt = $grammar->wrap($this->related->qualifyColumn($this->related->getRgtName())); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $this->addBalancedOrConstraints( $query, array_values($groups), function (Builder $query, array $group) use ($lft, $rgt): void { $points = array_values(array_unique(array_map( - static fn (Model $model): int => $model->getRgt(), /* @phpstan-ignore method.notFound */ + static fn (Model $model): int => $model->getRgt(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $group, ))); sort($points); @@ -132,8 +132,8 @@ protected function prepareEagerModels(array $models): array $rgts = []; foreach ($group as $model) { - $lfts[] = $model->getLft(); /* @phpstan-ignore method.notFound */ - $rgts[] = $model->getRgt(); /* @phpstan-ignore method.notFound */ + $lfts[] = $model->getLft(); + $rgts[] = $model->getRgt(); } $positions = array_keys($group); @@ -168,12 +168,12 @@ protected function matchMany(array $models, Collection $results): array $scope = $this->scopeKey($model); $key = $this->matchKey($model); /** @var int $lft */ - $lft = $model->getLft(); /* @phpstan-ignore method.notFound */ + $lft = $model->getLft(); $found = []; foreach ($results as $related) { - if ($related->getLft() < $lft /* @phpstan-ignore method.notFound */ - && $related->getRgt() > $lft /* @phpstan-ignore method.notFound */ + if ($related->getLft() < $lft /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + && $related->getRgt() > $lft /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ && $this->scopeKey($related) === $scope && ($key === null || $this->matchKey($related) !== $key) ) { @@ -201,7 +201,7 @@ protected function matchMany(array $models, Collection $results): array } $lfts = array_map( - static fn (Model $model): int => $model->getLft(), /* @phpstan-ignore method.notFound */ + static fn (Model $model): int => $model->getLft(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $group, ); $positions = array_keys($group); @@ -271,8 +271,8 @@ protected function requiredRelatedColumns(Model $model): array protected function intervalColumns(Model $model): array { $columns = array_fill_keys(array_keys($this->scopeValues($model)), false); - $columns[$model->getLftName()] = true; /* @phpstan-ignore method.notFound */ - $columns[$model->getRgtName()] = true; /* @phpstan-ignore method.notFound */ + $columns[$model->getLftName()] = true; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $columns[$model->getRgtName()] = true; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ return $columns; } diff --git a/src/nested-set/src/Eloquent/BaseRelation.php b/src/nested-set/src/Eloquent/BaseRelation.php index 221b49cc7d..1b4de823e5 100644 --- a/src/nested-set/src/Eloquent/BaseRelation.php +++ b/src/nested-set/src/Eloquent/BaseRelation.php @@ -112,8 +112,8 @@ public function getRelationExistenceQuery(EloquentBuilder $query, EloquentBuilde $condition = $this->relationExistenceCondition( $grammar->wrapTable($hash), $grammar->wrapTable($parentQuery->getModel()->getTable()), - $grammar->wrap($this->parent->getLftName()), /* @phpstan-ignore method.notFound */ - $grammar->wrap($this->parent->getRgtName()) /* @phpstan-ignore method.notFound */ + $grammar->wrap($this->parent->getLftName()), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $grammar->wrap($this->parent->getRgtName()) /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ); $query->whereRaw($condition); @@ -353,11 +353,11 @@ protected function sortedResultBuckets(Collection $results, bool $includeRgt): a $scope = $this->scopeKey($related); $buckets[$scope]['models'][] = $related; $buckets[$scope]['keys'][] = $this->matchKey($related); - $buckets[$scope]['lfts'][] = $related->getLft(); /* @phpstan-ignore method.notFound */ + $buckets[$scope]['lfts'][] = $related->getLft(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $buckets[$scope]['rgts'] ??= []; if ($includeRgt) { - $buckets[$scope]['rgts'][] = $related->getRgt(); /* @phpstan-ignore method.notFound */ + $buckets[$scope]['rgts'][] = $related->getRgt(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } $buckets[$scope]['positions'][] = $position++; @@ -422,7 +422,7 @@ protected function matchKey(Model $model): ?string */ protected function scopeValues(Model $model): array { - return $model->getNestedSetScope(); /* @phpstan-ignore method.notFound */ + return $model->getNestedSetScope(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } /** @@ -430,7 +430,7 @@ protected function scopeValues(Model $model): array */ protected function scopeKey(Model $model): string { - return $model->getNestedSetScopeKey(); /* @phpstan-ignore method.notFound */ + return $model->getNestedSetScopeKey(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } /** @@ -438,7 +438,7 @@ protected function scopeKey(Model $model): string */ public function getForeignKeyName(): string { - return $this->parent->getParentIdName(); /* @phpstan-ignore method.notFound */ + return $this->parent->getParentIdName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } /** diff --git a/src/nested-set/src/Eloquent/Collection.php b/src/nested-set/src/Eloquent/Collection.php index 584e769fa2..0ca3ba2cac 100644 --- a/src/nested-set/src/Eloquent/Collection.php +++ b/src/nested-set/src/Eloquent/Collection.php @@ -40,7 +40,7 @@ public function linkNodes(): static protected function linkNodesFromGroups(array $groupedNodes, array $roots): static { foreach ($this->items as $node) { - $parentId = $node->getParentId(); /* @phpstan-ignore method.notFound */ + $parentId = $node->getParentId(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if ($parentId === null) { $node->setRelation('parent', null); @@ -127,7 +127,7 @@ protected function getRootNodeId(Model|int|string|false|null $root = false): int $rootNodeId = null; foreach ($this->items as $node) { - $lftName = $node->getLftName(); /* @phpstan-ignore method.notFound */ + $lftName = $node->getLftName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if (! array_key_exists($lftName, $node->getAttributes())) { throw new LogicException(sprintf( @@ -137,11 +137,11 @@ protected function getRootNodeId(Model|int|string|false|null $root = false): int )); } - $lft = $node->getLft(); /* @phpstan-ignore method.notFound */ + $lft = $node->getLft(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if ($lft !== null && $lft < $leastValue) { $leastValue = $lft; - $rootNodeId = $node->getParentId(); /* @phpstan-ignore method.notFound */ + $rootNodeId = $node->getParentId(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } } @@ -186,7 +186,7 @@ protected function groupNodesByParent(): array )); } - $parentIdName = $node->getParentIdName(); /* @phpstan-ignore method.notFound */ + $parentIdName = $node->getParentIdName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if (! array_key_exists($parentIdName, $node->getAttributes())) { throw new LogicException(sprintf( @@ -196,7 +196,7 @@ protected function groupNodesByParent(): array )); } - $parentId = $node->getParentId(); /* @phpstan-ignore method.notFound */ + $parentId = $node->getParentId(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if ($parentId === null) { $roots[] = $node; diff --git a/src/nested-set/src/Eloquent/DescendantsRelation.php b/src/nested-set/src/Eloquent/DescendantsRelation.php index 5359f618d0..31bf4b5d5c 100644 --- a/src/nested-set/src/Eloquent/DescendantsRelation.php +++ b/src/nested-set/src/Eloquent/DescendantsRelation.php @@ -48,15 +48,15 @@ protected function constrainEagerModels(Builder $query, array $models): void protected function addEagerConstraint(Builder $query, Model $model): void { $query->whereNested(function (Builder $query) use ($model): void { - $model->applyNestedSetScope($query); /* @phpstan-ignore method.notFound */ + $model->applyNestedSetScope($query); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ // Validated integer bounds are inlined, as whereIntegerInRaw() does, // so large eager loads do not exhaust the driver's bound parameters. $query->whereRaw(sprintf( '%s between %d and %d', - $query->getGrammar()->wrap($this->related->qualifyColumn($this->related->getLftName())), /* @phpstan-ignore method.notFound */ - $model->getLft() + 1, /* @phpstan-ignore method.notFound */ - $model->getRgt(), /* @phpstan-ignore method.notFound */ + $query->getGrammar()->wrap($this->related->qualifyColumn($this->related->getLftName())), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $model->getLft() + 1, /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $model->getRgt(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ )); }, 'or'); } @@ -70,9 +70,7 @@ protected function prepareEagerModels(array $models): array foreach (parent::prepareEagerModels($models) as $model) { // Without a bound strictly between the parent's bounds, no node can be its descendant. - if ($model->getRgt() /* @phpstan-ignore method.notFound */ - <= $model->getLft() + 1 /* @phpstan-ignore method.notFound */ - ) { + if ($model->getRgt() <= $model->getLft() + 1) { continue; } @@ -86,8 +84,8 @@ protected function prepareEagerModels(array $models): array $rgts = []; foreach ($group as $model) { - $lfts[] = $model->getLft(); /* @phpstan-ignore method.notFound */ - $rgts[] = $model->getRgt(); /* @phpstan-ignore method.notFound */ + $lfts[] = $model->getLft(); + $rgts[] = $model->getRgt(); } $positions = array_keys($group); @@ -122,13 +120,13 @@ protected function matchMany(array $models, Collection $results): array $scope = $this->scopeKey($model); $key = $this->matchKey($model); /** @var int $lft */ - $lft = $model->getLft(); /* @phpstan-ignore method.notFound */ + $lft = $model->getLft(); /** @var int $rgt */ - $rgt = $model->getRgt(); /* @phpstan-ignore method.notFound */ + $rgt = $model->getRgt(); $found = []; foreach ($results as $related) { - $relatedLft = $related->getLft(); /* @phpstan-ignore method.notFound */ + $relatedLft = $related->getLft(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if ($relatedLft > $lft && $relatedLft < $rgt @@ -154,9 +152,9 @@ protected function matchMany(array $models, Collection $results): array $key = $this->matchKey($model); /** @var int $rgt */ - $rgt = $model->getRgt(); /* @phpstan-ignore method.notFound */ + $rgt = $model->getRgt(); $found = []; - $index = static::lowerBound($bucket['lfts'], $model->getLft() + 1); /* @phpstan-ignore method.notFound */ + $index = static::lowerBound($bucket['lfts'], $model->getLft() + 1); for ($count = count($bucket['lfts']); $index < $count && $bucket['lfts'][$index] < $rgt; ++$index) { if ($key === null || $bucket['keys'][$index] !== $key) { @@ -197,8 +195,8 @@ protected static function lowerBound(array $values, int $needle): int protected function requiredParentColumns(Model $model): array { $columns = $this->scopeColumns($model); - $columns[$model->getLftName()] = true; /* @phpstan-ignore method.notFound */ - $columns[$model->getRgtName()] = true; /* @phpstan-ignore method.notFound */ + $columns[$model->getLftName()] = true; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $columns[$model->getRgtName()] = true; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ return $columns; } @@ -209,7 +207,7 @@ protected function requiredParentColumns(Model $model): array protected function requiredRelatedColumns(Model $model): array { $columns = $this->scopeColumns($model); - $columns[$model->getLftName()] = true; /* @phpstan-ignore method.notFound */ + $columns[$model->getLftName()] = true; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ return $columns; } diff --git a/src/nested-set/src/Eloquent/QueryBuilder.php b/src/nested-set/src/Eloquent/QueryBuilder.php index 29ad583f22..1325ccf788 100644 --- a/src/nested-set/src/Eloquent/QueryBuilder.php +++ b/src/nested-set/src/Eloquent/QueryBuilder.php @@ -30,9 +30,9 @@ public function getNodeData(int|string $id, bool $required = false): array { $this->ensureConcreteNestedSetScope('node lookup'); - $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound */ - $rgtName = $this->model->getRgtName(); /* @phpstan-ignore method.notFound */ - $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $rgtName = $this->model->getRgtName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ // first() ignores its columns once a global scope has selected some, // so select() replaces any scope projection with the structural columns. @@ -56,8 +56,8 @@ public function getPlainNodeData(int|string $id, bool $required = false): array $data = $this->getNodeData($id, $required); return [ - $data[$this->model->getLftName()] ?? 0, /* @phpstan-ignore method.notFound */ - $data[$this->model->getRgtName()] ?? 0, /* @phpstan-ignore method.notFound */ + $data[$this->model->getLftName()] ?? 0, /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $data[$this->model->getRgtName()] ?? 0, /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ]; } @@ -66,7 +66,7 @@ public function getPlainNodeData(int|string $id, bool $required = false): array */ public function whereIsRoot(): static { - $this->query->whereNull($this->model->qualifyColumn($this->model->getParentIdName())); /* @phpstan-ignore method.notFound */ + $this->query->whereNull($this->model->qualifyColumn($this->model->getParentIdName())); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ return $this; } @@ -82,14 +82,14 @@ public function whereAncestorOf(Model|int|string $id, bool $andSelf = false, str $model = $id; $this->ensureUsableNodeForPositionalQuery($model); $value = '?'; - $bindings = [$id->getRgt()]; /* @phpstan-ignore method.notFound */ + $bindings = [$id->getRgt()]; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } else { $this->ensureConcreteNestedSetScope('scalar lookup'); $valueQuery = $this->model - ->newNestedSetQuery('_n') /* @phpstan-ignore method.notFound */ + ->newNestedSetQuery('_n') /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->toBase() - ->select('_n.' . $this->model->getRgtName()) /* @phpstan-ignore method.notFound */ + ->select('_n.' . $this->model->getRgtName()) /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->from($this->model->getTable() . ' as _n') ->where('_n.' . $this->model->getKeyName(), '=', $id); @@ -112,11 +112,11 @@ public function whereAncestorOf(Model|int|string $id, bool $andSelf = false, str $id, ); } else { - $inner->whereRaw("{$rgt} > ?", [$model->getRgt()]); /* @phpstan-ignore method.notFound */ + $inner->whereRaw("{$rgt} > ?", [$model->getRgt()]); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } } if ($model !== null) { - $model->applyNestedSetScope($inner); /* @phpstan-ignore method.notFound */ + $model->applyNestedSetScope($inner); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } }, $boolean); @@ -165,7 +165,7 @@ public function ancestorsAndSelf(Model|int|string $id, array $columns = ['*']): public function whereNodeBetween(array $values, string $boolean = 'and', bool $not = false, ?BaseQueryBuilder $query = null): static { ($query ?? $this->query)->whereBetween( - $this->model->qualifyColumn($this->model->getLftName()), /* @phpstan-ignore method.notFound */ + $this->model->qualifyColumn($this->model->getLftName()), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $values, $boolean, $not, @@ -190,8 +190,8 @@ public function whereDescendantOf(Model|int|string $id, string $boolean = 'and', $this->query->whereNested(function (BaseQueryBuilder $inner) use ($id, $andSelf, $not) { if ($id instanceof Model) { $this->ensureUsableNodeForPositionalQuery($id); - $id->applyNestedSetScope($inner); /* @phpstan-ignore method.notFound */ - $data = $id->getBounds(); /* @phpstan-ignore method.notFound */ + $id->applyNestedSetScope($inner); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $data = $id->getBounds(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } else { $this->ensureConcreteNestedSetScope('scalar lookup'); @@ -276,14 +276,14 @@ protected function whereIsBeforeOrAfter(Model|int|string $id, string $operator, $model = $id; $this->ensureUsableNodeForPositionalQuery($model); $value = '?'; - $bindings = [$id->getLft()]; /* @phpstan-ignore method.notFound */ + $bindings = [$id->getLft()]; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } else { $this->ensureConcreteNestedSetScope('scalar lookup'); $valueQuery = $this->model - ->newNestedSetQuery('_n') /* @phpstan-ignore method.notFound */ + ->newNestedSetQuery('_n') /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->toBase() - ->select('_n.' . $this->model->getLftName()) /* @phpstan-ignore method.notFound */ + ->select('_n.' . $this->model->getLftName()) /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->from($this->model->getTable() . ' as _n') ->where('_n.' . $this->model->getKeyName(), '=', $id); @@ -303,7 +303,7 @@ protected function whereIsBeforeOrAfter(Model|int|string $id, string $operator, $bindings, ) { if ($model !== null) { - $model->applyNestedSetScope($inner); /* @phpstan-ignore method.notFound */ + $model->applyNestedSetScope($inner); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } $inner->whereRaw("{$lft} {$operator} {$value}", $bindings); @@ -360,7 +360,7 @@ public function withDepth(string $as = 'depth'): static } $grammar = $this->query->getGrammar(); - $column = $grammar->wrap($this->model->qualifyColumn($this->model->getDepthName())); /* @phpstan-ignore method.notFound */ + $column = $grammar->wrap($this->model->qualifyColumn($this->model->getDepthName())); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $alias = $grammar->wrap($as); $this->query->selectRaw("{$column} as {$alias}"); @@ -376,8 +376,8 @@ protected function wrappedColumns(): array $grammar = $this->query->getGrammar(); return [ - $grammar->wrap($this->model->qualifyColumn($this->model->getLftName())), /* @phpstan-ignore method.notFound */ - $grammar->wrap($this->model->qualifyColumn($this->model->getRgtName())), /* @phpstan-ignore method.notFound */ + $grammar->wrap($this->model->qualifyColumn($this->model->getLftName())), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $grammar->wrap($this->model->qualifyColumn($this->model->getRgtName())), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ]; } @@ -387,7 +387,7 @@ protected function wrappedColumns(): array public function withoutRoot(): static { $this->query->whereNotNull( - $this->model->qualifyColumn($this->model->getParentIdName()), /* @phpstan-ignore method.notFound */ + $this->model->qualifyColumn($this->model->getParentIdName()), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ); return $this; @@ -399,7 +399,7 @@ public function withoutRoot(): static public function hasParent(): static { $this->query->whereNotNull( - $this->model->qualifyColumn($this->model->getParentIdName()), /* @phpstan-ignore method.notFound */ + $this->model->qualifyColumn($this->model->getParentIdName()), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ); return $this; @@ -424,7 +424,7 @@ public function defaultOrder(string $dir = 'asc'): static { $this->query->reorder(); - $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound */ + $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ // A compound query can order only by columns projected in its result. $column = $this->query->unions @@ -462,9 +462,9 @@ public function moveNode( $this->query->useWritePdo(); $data = $nodeData ?: $this->newNestedSetLookupQuery()->getNodeData($key, true); - $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound */ - $rgtName = $this->model->getRgtName(); /* @phpstan-ignore method.notFound */ - $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $rgtName = $this->model->getRgtName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ foreach ([$lftName, $rgtName, $depthName] as $column) { if (! isset($data[$column])) { @@ -513,8 +513,8 @@ public function moveNode( $boundary = [$from, $to]; $query = $this->toBase() - ->where($this->model->getRgtName(), '>=', $boundary[0]) /* @phpstan-ignore method.notFound */ - ->where($this->model->getLftName(), '<=', $boundary[1]); /* @phpstan-ignore method.notFound */ + ->where($this->model->getRgtName(), '>=', $boundary[0]) /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + ->where($this->model->getLftName(), '<=', $boundary[1]); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ return $query->update($this->patch($params)); } @@ -530,11 +530,11 @@ public function depthForPosition(int $position): int // A base read keeps this internal lookup from hydrating a model and firing retrieved listeners. $depth = $this->newNestedSetLookupQuery() - ->where($this->model->getLftName(), '<', $position) /* @phpstan-ignore method.notFound */ - ->where($this->model->getRgtName(), '>=', $position) /* @phpstan-ignore method.notFound */ - ->orderBy($this->model->getLftName(), 'desc') /* @phpstan-ignore method.notFound */ + ->where($this->model->getLftName(), '<', $position) /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + ->where($this->model->getRgtName(), '>=', $position) /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + ->orderBy($this->model->getLftName(), 'desc') /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->toBase() - ->value($this->model->getDepthName()); /* @phpstan-ignore method.notFound */ + ->value($this->model->getDepthName()); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ return $depth === null ? 0 : ((int) $depth + 1); } @@ -544,7 +544,7 @@ public function depthForPosition(int $position): int */ protected function newNestedSetLookupQuery(?string $table = null): self { - $query = $this->model->newNestedSetQuery($table); /* @phpstan-ignore method.notFound */ + $query = $this->model->newNestedSetQuery($table); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ return $this->query->useWritePdo ? $query->useWritePdo() : $query; } @@ -573,7 +573,7 @@ public function makeGap(int $cut, int $height): int $params = compact('cut', 'height'); $query = $this->toBase() - ->where($this->model->getRgtName(), '>=', $cut); /* @phpstan-ignore method.notFound */ + ->where($this->model->getRgtName(), '>=', $cut); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ return $query->update($this->patch($params)); } @@ -590,13 +590,13 @@ protected function patch(array $params): array // MySQL and MariaDB evaluate assignments in order, so depth must read // the original left bound before the interval columns are updated. if (($params['depth'] ?? 0) !== 0) { - $column = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + $column = $this->model->getDepthName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $columns[$column] = $this->depthPatch($grammar->wrap($column), $params); } foreach ([ - $this->model->getLftName(), /* @phpstan-ignore method.notFound */ - $this->model->getRgtName(), /* @phpstan-ignore method.notFound */ + $this->model->getLftName(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $this->model->getRgtName(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ] as $col) { $columns[$col] = $this->columnPatch($grammar->wrap($col), $params); } @@ -616,7 +616,7 @@ protected function depthPatch(string $column, array $params): Expression $distance = abs($depth); return new Expression( - "case when {$this->query->getGrammar()->wrap($this->model->getLftName())} " /* @phpstan-ignore method.notFound */ + "case when {$this->query->getGrammar()->wrap($this->model->getLftName())} " /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ . "between {$lft} and {$rgt} then {$column} {$operator} {$distance} else {$column} end" ); } @@ -710,7 +710,7 @@ protected function getInvalidIntervalsQuery(bool $count = true): BaseQueryBuilde protected function getEndpointEventsQuery(): BaseQueryBuilder { [$lft, $rgt] = $this->wrappedColumns(); - $depth = $this->query->getGrammar()->wrap($this->model->getDepthName()); /* @phpstan-ignore method.notFound */ + $depth = $this->query->getGrammar()->wrap($this->model->getDepthName()); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ // A left endpoint opens a node at its stored depth, while a right // endpoint closes it after one additional active level. @@ -812,7 +812,7 @@ protected function getMissingParentQuery(bool $count = true): BaseQueryBuilder { $childAlias = 'nested_set_child'; $parentAlias = 'nested_set_parent'; - $parentIdName = $this->model->getParentIdName(); /* @phpstan-ignore method.notFound */ + $parentIdName = $this->model->getParentIdName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $keyName = $this->model->getKeyName(); $query = $this->newNestedSetLookupQuery($childAlias) @@ -843,11 +843,11 @@ protected function getWrongParentQuery(bool $count = true): BaseQueryBuilder { $childAlias = 'nested_set_child'; $parentAlias = 'nested_set_parent'; - $parentIdName = $this->model->getParentIdName(); /* @phpstan-ignore method.notFound */ + $parentIdName = $this->model->getParentIdName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $keyName = $this->model->getKeyName(); - $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound */ - $rgtName = $this->model->getRgtName(); /* @phpstan-ignore method.notFound */ - $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $rgtName = $this->model->getRgtName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $grammar = $this->query->getGrammar(); $query = $this->newNestedSetLookupQuery($childAlias) @@ -899,9 +899,9 @@ protected function getWrongDepthQuery(bool $count = true): BaseQueryBuilder { $childAlias = 'nested_set_child'; $parentAlias = 'nested_set_parent'; - $parentIdName = $this->model->getParentIdName(); /* @phpstan-ignore method.notFound */ + $parentIdName = $this->model->getParentIdName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $keyName = $this->model->getKeyName(); - $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $grammar = $this->query->getGrammar(); $query = $this->newNestedSetLookupQuery($childAlias) @@ -960,7 +960,7 @@ protected function addScopeColumnComparisons( string $firstAlias, string $secondAlias, ): void { - foreach (array_keys($this->model->getNestedSetScope()) as $attribute) { /* @phpstan-ignore method.notFound */ + foreach (array_keys($this->model->getNestedSetScope()) as $attribute) { /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $first = $firstAlias . '.' . $attribute; $second = $secondAlias . '.' . $attribute; @@ -981,7 +981,7 @@ protected function ensureConcreteNestedSetScope(string $operation = 'diagnostics { $attributes = $this->model->getAttributes(); - foreach (array_keys($this->model->getNestedSetScope()) as $attribute) { /* @phpstan-ignore method.notFound */ + foreach (array_keys($this->model->getNestedSetScope()) as $attribute) { /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if (! array_key_exists($attribute, $attributes)) { throw new LogicException(sprintf( 'Nested set %s for [%s] requires a concrete scoped([...]) selection because attribute [%s] was not selected.', @@ -1015,8 +1015,8 @@ protected function ensureUsableNodeForPositionalQuery(Model $node): void )); } - if ($node->getLft() === null /* @phpstan-ignore method.notFound */ - || $node->getRgt() === null /* @phpstan-ignore method.notFound */ + if ($node->getLft() === null /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + || $node->getRgt() === null /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ) { throw new LogicException(sprintf( 'Nested set node [%s] must have loaded bounds.', @@ -1026,7 +1026,7 @@ protected function ensureUsableNodeForPositionalQuery(Model $node): void $attributes = $node->getAttributes(); - foreach (array_keys($node->getNestedSetScope()) as $attribute) { /* @phpstan-ignore method.notFound */ + foreach (array_keys($node->getNestedSetScope()) as $attribute) { /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if (! array_key_exists($attribute, $attributes)) { throw new LogicException(sprintf( 'Nested set node [%s] must have scope attribute [%s] selected.', @@ -1069,11 +1069,11 @@ protected function prepareRepairRoot(Model $root, string $operation): void )); } - $root->prepareForNestedSetMutation(); /* @phpstan-ignore method.notFound */ + $root->prepareForNestedSetMutation(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ - if ($this->model->hasCompleteNestedSetScope() /* @phpstan-ignore method.notFound */ - && $this->model->getNestedSetScopeKey() /* @phpstan-ignore method.notFound */ - !== $root->getNestedSetScopeKey() /* @phpstan-ignore method.notFound */ + if ($this->model->hasCompleteNestedSetScope() /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + && $this->model->getNestedSetScopeKey() /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + !== $root->getNestedSetScopeKey() /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ) { throw new LogicException(sprintf( 'Nested set %s root [%s] does not match the query scoped([...]) selection.', @@ -1091,10 +1091,10 @@ protected function prepareRepairRoot(Model $root, string $operation): void protected function ensureSubtreeBoundsAreValid(Model $root): void { $keyName = $root->getKeyName(); - $parentIdName = $root->getParentIdName(); /* @phpstan-ignore method.notFound */ - $lftName = $root->getLftName(); /* @phpstan-ignore method.notFound */ - $lft = $root->getLft(); /* @phpstan-ignore method.notFound */ - $rgt = $root->getRgt(); /* @phpstan-ignore method.notFound */ + $parentIdName = $root->getParentIdName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $lftName = $root->getLftName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $lft = $root->getLft(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $rgt = $root->getRgt(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if ($lft === null || $rgt === null || $lft < 1 || $rgt <= $lft) { throw new LogicException(sprintf( @@ -1107,12 +1107,12 @@ protected function ensureSubtreeBoundsAreValid(Model $root): void $bounds = [$lft, $rgt]; $selectedKeys = $root - ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ + ->newNestedSetQuery() /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->select($keyName) ->whereBetween($lftName, $bounds); $crossesBoundary = $root - ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ + ->newNestedSetQuery() /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->useWritePdo() ->whereIn($parentIdName, $selectedKeys) ->whereNotBetween($lftName, $bounds) @@ -1169,14 +1169,14 @@ public function fixTree(?Model $root = null, array $extraColumns = []): int } $model = $root ?? $this->model; - $parentIdName = $model->getParentIdName(); /* @phpstan-ignore method.notFound */ - $scopeColumns = array_keys($model->getNestedSetScope()); /* @phpstan-ignore method.notFound */ + $parentIdName = $model->getParentIdName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $scopeColumns = array_keys($model->getNestedSetScope()); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $columns = array_values(array_unique([ $model->getKeyName(), $parentIdName, - $model->getLftName(), /* @phpstan-ignore method.notFound */ - $model->getRgtName(), /* @phpstan-ignore method.notFound */ - $model->getDepthName(), /* @phpstan-ignore method.notFound */ + $model->getLftName(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $model->getRgtName(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $model->getDepthName(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ...$scopeColumns, ...$extraColumns, ])); @@ -1184,7 +1184,7 @@ public function fixTree(?Model $root = null, array $extraColumns = []): int // Repair reads plain rows and hydrates only the nodes it saves, so a // large, mostly healthy tree is not loaded as models. $rows = $model - ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ + ->newNestedSetQuery() /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->useWritePdo() ->when($root, function (self $query) use ($root) { return $query->whereDescendantOf($root); @@ -1223,8 +1223,8 @@ protected function fixNodes( ): int { $model = $parent ?? $this->model; $parentId = $parent?->getKey(); - $cut = $parent ? $parent->getLft() + 1 : 1; /* @phpstan-ignore method.notFound */ - $depth = $parent ? $parent->getDepth() + 1 : 0; /* @phpstan-ignore method.notFound */ + $cut = $parent ? $parent->getLft() + 1 : 1; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $depth = $parent ? $parent->getDepth() + 1 : 0; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $placements = []; $moved = 0; @@ -1274,13 +1274,13 @@ protected function fixNodes( ); } - $grown = $parent ? $cut - $parent->getRgt() : 0; /* @phpstan-ignore method.notFound */ + $grown = $parent ? $cut - $parent->getRgt() : 0; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $gapCut = null; if ($parent !== null && $grown !== 0) { - $gapCut = $parent->getRgt() + 1; /* @phpstan-ignore method.notFound */ + $gapCut = $parent->getRgt() + 1; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $moved = $parent - ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ + ->newNestedSetQuery() /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->makeGap($gapCut, $grown); } @@ -1293,11 +1293,11 @@ protected function fixNodes( } if ($parent !== null && $gapCut !== null) { - static::saveRepairNode($parent->rawNode( /* @phpstan-ignore method.notFound */ - $parent->getLft(), /* @phpstan-ignore method.notFound */ + static::saveRepairNode($parent->rawNode( /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $parent->getLft(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $cut, - $parent->getParentId(), /* @phpstan-ignore method.notFound */ - $parent->getDepth(), /* @phpstan-ignore method.notFound */ + $parent->getParentId(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $parent->getDepth(), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ )); ++$changed; @@ -1317,8 +1317,8 @@ protected function fixNodes( protected function applyRepairPlacement(Model $model, array $placement, ?int $gapCut, int $height): bool { [$node, $lft, $rgt, $parentId, $depth] = $placement; - $lftName = $model->getLftName(); /* @phpstan-ignore method.notFound */ - $rgtName = $model->getRgtName(); /* @phpstan-ignore method.notFound */ + $lftName = $model->getLftName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $rgtName = $model->getRgtName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if ($node instanceof Model) { static::assignRepairNode($node, $lft, $rgt, $parentId, $depth); @@ -1366,10 +1366,10 @@ protected function applyRepairPlacement(Model $model, array $placement, ?int $ga protected function repairRowMatches(Model $model, stdClass $row, array $placement): bool { $stored = [ - 1 => $row->{$model->getLftName()}, /* @phpstan-ignore method.notFound */ - 2 => $row->{$model->getRgtName()}, /* @phpstan-ignore method.notFound */ - 3 => $row->{$model->getParentIdName()}, /* @phpstan-ignore method.notFound */ - 4 => $row->{$model->getDepthName()}, /* @phpstan-ignore method.notFound */ + 1 => $row->{$model->getLftName()}, /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + 2 => $row->{$model->getRgtName()}, /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + 3 => $row->{$model->getParentIdName()}, /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + 4 => $row->{$model->getDepthName()}, /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ]; foreach ($stored as $index => $value) { @@ -1487,10 +1487,10 @@ protected static function assignRepairNode( int|string|null $parentId, ?int $depth, ): void { - $model->setLft($lft); /* @phpstan-ignore method.notFound */ - $model->setRgt($rgt); /* @phpstan-ignore method.notFound */ - $model->setParentId($parentId); /* @phpstan-ignore method.notFound */ - $model->setDepth($depth); /* @phpstan-ignore method.notFound */ + $model->setLft($lft); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $model->setRgt($rgt); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $model->setParentId($parentId); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ + $model->setDepth($depth); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } /** @@ -1552,7 +1552,7 @@ public function rebuildTree(array $data, bool $delete = false, ?Model $root = nu $model = $root ?? $this->model; $result = $model - ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ + ->newNestedSetQuery() /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->useWritePdo() ->when($root, function (self $query) use ($root) { return $query->whereDescendantOf($root); @@ -1566,7 +1566,7 @@ public function rebuildTree(array $data, bool $delete = false, ?Model $root = nu $parentId = $root?->getKey(); $scopeAttributes = array_intersect_key( $model->getAttributes(), - array_flip(array_keys($model->getNestedSetScope())), /* @phpstan-ignore method.notFound */ + array_flip(array_keys($model->getNestedSetScope())), /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ); $this->buildRebuildDictionary( @@ -1585,9 +1585,9 @@ public function rebuildTree(array $data, bool $delete = false, ?Model $root = nu if ($delete && ! $usesSoftDeletes) { // MySQL and MariaDB check a restricting parent key as each row is deleted. $model - ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ + ->newNestedSetQuery() /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->whereIn($model->getKeyName(), array_keys($existing)) - ->orderBy($model->getLftName(), 'desc') /* @phpstan-ignore method.notFound */ + ->orderBy($model->getLftName(), 'desc') /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ ->delete(); } else { $deletedAtColumn = $delete && $usesSoftDeletes @@ -1609,7 +1609,7 @@ public function rebuildTree(array $data, bool $delete = false, ?Model $root = nu $childrenByParent, $parentOrder, $existingModel, - $existingModel->getParentId(), /* @phpstan-ignore method.notFound */ + $existingModel->getParentId(), ); } } @@ -1655,7 +1655,7 @@ protected function buildRebuildDictionary( $model->setRawAttributes(array_replace($model->getAttributes(), $scopeAttributes)); // Set temporary values without scheduling a tree action. - $model->rawNode(0, 0, $parentId, 0); /* @phpstan-ignore method.notFound */ + $model->rawNode(0, 0, $parentId, 0); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ } else { $key = $itemData[$keyName]; @@ -1692,7 +1692,7 @@ protected function buildRebuildDictionary( $model->fill($itemData); static::saveRepairNode($model); - static::addRepairNode($roots, $childrenByParent, $parentOrder, $model, $model->getParentId()); /* @phpstan-ignore method.notFound */ + static::addRepairNode($roots, $childrenByParent, $parentOrder, $model, $model->getParentId()); if ($children === null) { continue; @@ -1715,7 +1715,7 @@ protected function buildRebuildDictionary( */ public function applyNestedSetScope(?string $table = null): static { - /* @phpstan-ignore method.notFound */ + /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ return $this->model->applyNestedSetScope($this, $table); } diff --git a/src/nested-set/src/Eloquent/SiblingsRelation.php b/src/nested-set/src/Eloquent/SiblingsRelation.php index ec0a2825e6..8f889dbcbb 100644 --- a/src/nested-set/src/Eloquent/SiblingsRelation.php +++ b/src/nested-set/src/Eloquent/SiblingsRelation.php @@ -48,7 +48,7 @@ public function addConstraints(): void $this->whereParentId($this->query, $this->parent); - $this->parent->applyNestedSetScope($this->query); /* @phpstan-ignore method.notFound */ + $this->parent->applyNestedSetScope($this->query); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if (! $this->andSelf) { $this->query->where( @@ -67,7 +67,7 @@ protected function addEagerConstraint(Builder $query, Model $model): void $query->whereNested(function (Builder $query) use ($model): void { $this->whereParentId($query, $model); - $model->applyNestedSetScope($query); /* @phpstan-ignore method.notFound */ + $model->applyNestedSetScope($query); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if (! $this->andSelf) { $query->where( @@ -138,7 +138,7 @@ protected function matchMany(array $models, Collection $results): array foreach ($results as $related) { $scope = $this->scopeKey($related); - $parentId = $related->getParentId(); /* @phpstan-ignore method.notFound */ + $parentId = $related->getParentId(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if ($parentId === null) { $index[$scope]['roots'][] = $related; @@ -160,7 +160,7 @@ protected function matchMany(array $models, Collection $results): array continue; } - $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ + $parentId = $model->getParentId(); $siblings = $parentId === null ? ($scope['roots'] ?? []) : ($scope['parents'][$parentId] ?? []); if ($this->andSelf) { @@ -207,7 +207,7 @@ protected function requiredRelatedColumns(Model $model): array protected function siblingColumns(Model $model): array { $columns = array_fill_keys(array_keys($this->scopeValues($model)), false); - $columns[$model->getParentIdName()] = false; /* @phpstan-ignore method.notFound */ + $columns[$model->getParentIdName()] = false; /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ if (! $this->andSelf) { $columns[$model->getKeyName()] = true; @@ -240,9 +240,9 @@ protected function relationExistenceCondition(string $hash, string $table, strin */ protected function whereParentId(Builder|QueryBuilder $query, Model $model): void { - $parentIdName = $model->getParentIdName(); /* @phpstan-ignore method.notFound */ + $parentIdName = $model->getParentIdName(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $qualifiedParentIdName = $model->qualifyColumn($parentIdName); - $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ + $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound (HasNode method; a template bound cannot name a trait) */ $parentId === null ? $query->whereNull($qualifiedParentIdName) diff --git a/src/notifications/src/AnonymousNotifiable.php b/src/notifications/src/AnonymousNotifiable.php index 5ee194c81f..ccb9daab4f 100644 --- a/src/notifications/src/AnonymousNotifiable.php +++ b/src/notifications/src/AnonymousNotifiable.php @@ -18,6 +18,8 @@ class AnonymousNotifiable /** * Add routing information to the target. * + * @return $this + * * @throws InvalidArgumentException */ public function route(string $channel, mixed $route): static diff --git a/src/notifications/src/ChannelManager.php b/src/notifications/src/ChannelManager.php index d281672ba6..6447958242 100644 --- a/src/notifications/src/ChannelManager.php +++ b/src/notifications/src/ChannelManager.php @@ -171,6 +171,8 @@ public function deliverVia(string $channel): void /** * Set the locale of notifications. + * + * @return $this */ public function locale(string $locale): static { diff --git a/src/notifications/src/Channels/MailChannel.php b/src/notifications/src/Channels/MailChannel.php index 6965a37b14..247784b7ea 100644 --- a/src/notifications/src/Channels/MailChannel.php +++ b/src/notifications/src/Channels/MailChannel.php @@ -38,7 +38,7 @@ public function __construct( */ public function send(mixed $notifiable, Notification $notification): ?SentMessage { - $message = $notification->toMail($notifiable); // @phpstan-ignore method.notFound + $message = $notification->toMail($notifiable); // @phpstan-ignore method.notFound (notifications sent through this channel define toMail()) if (! $notifiable->routeNotificationFor('mail', $notification) && ! $message instanceof Mailable @@ -244,6 +244,8 @@ protected function addAttachments(Message $mailMessage, MailMessage $message): v /** * Run the callbacks for the message. + * + * @return $this */ protected function runCallbacks(Message $mailMessage, MailMessage $message): static { diff --git a/src/notifications/src/DatabaseNotification.php b/src/notifications/src/DatabaseNotification.php index d5b8815cd3..c1293cd3c9 100644 --- a/src/notifications/src/DatabaseNotification.php +++ b/src/notifications/src/DatabaseNotification.php @@ -27,16 +27,19 @@ class DatabaseNotification extends Model /** * The "type" of the primary key ID. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Table] would override a subclass's $keyType = 'int') protected string $keyType = 'string'; /** * Indicates if the IDs are auto-incrementing. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutIncrementing] would override a subclass's $incrementing = true) public bool $incrementing = false; /** * The table associated with the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'notifications'; /** @@ -44,6 +47,7 @@ class DatabaseNotification extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** @@ -120,6 +124,7 @@ private function readAt(): ?CarbonInterface * @param Builder $query * @return Builder */ + // @phpstan-ignore hypervel.noPublicModelScopeMethod (public API, kept as Laravel declares it) public function scopeRead(Builder $query): Builder { $query->whereNotNull('read_at'); @@ -133,6 +138,7 @@ public function scopeRead(Builder $query): Builder * @param Builder $query * @return Builder */ + // @phpstan-ignore hypervel.noPublicModelScopeMethod (public API, kept as Laravel declares it) public function scopeUnread(Builder $query): Builder { $query->whereNull('read_at'); diff --git a/src/notifications/src/Messages/BroadcastMessage.php b/src/notifications/src/Messages/BroadcastMessage.php index 2ce05541ba..6f0bb6daba 100644 --- a/src/notifications/src/Messages/BroadcastMessage.php +++ b/src/notifications/src/Messages/BroadcastMessage.php @@ -22,6 +22,8 @@ public function __construct( /** * Set the message data. + * + * @return $this */ public function data(array $data): static { diff --git a/src/notifications/src/Messages/MailMessage.php b/src/notifications/src/Messages/MailMessage.php index a366462fbe..2a00f7e6e5 100644 --- a/src/notifications/src/Messages/MailMessage.php +++ b/src/notifications/src/Messages/MailMessage.php @@ -39,31 +39,43 @@ class MailMessage extends SimpleMessage implements Renderable /** * The "from" information for the message. + * + * @var array{0: array|string, 1?: null|string, ...}|array{} */ public array $from = []; /** * The "reply to" information for the message. + * + * @var array */ public array $replyTo = []; /** * The "cc" information for the message. + * + * @var array */ public array $cc = []; /** * The "bcc" information for the message. + * + * @var array */ public array $bcc = []; /** * The attachments for the message. + * + * @var array */ public array $attachments = []; /** * The raw attachments for the message. + * + * @var array */ public array $rawAttachments = []; @@ -91,6 +103,8 @@ class MailMessage extends SimpleMessage implements Renderable /** * Set the view for the mail message. + * + * @return $this */ public function view(array|string $view, array $data = []): static { @@ -104,6 +118,8 @@ public function view(array|string $view, array $data = []): static /** * Set the plain text view for the mail message. + * + * @return $this */ public function text(string $textView, array $data = []): static { @@ -115,6 +131,8 @@ public function text(string $textView, array $data = []): static /** * Set the Markdown template for the notification. + * + * @return $this */ public function markdown(string $view, array $data = []): static { @@ -128,6 +146,8 @@ public function markdown(string $view, array $data = []): static /** * Set the default markdown template. + * + * @return $this */ public function template(string $template): static { @@ -138,6 +158,8 @@ public function template(string $template): static /** * Set the theme to use with the Markdown template. + * + * @return $this */ public function theme(string $theme): static { @@ -148,6 +170,8 @@ public function theme(string $theme): static /** * Set the from address for the mail message. + * + * @return $this */ public function from(string $address, ?string $name = null): static { @@ -158,6 +182,8 @@ public function from(string $address, ?string $name = null): static /** * Set the "reply to" address of the message. + * + * @return $this */ public function replyTo(mixed $address, ?string $name = null): static { @@ -172,6 +198,8 @@ public function replyTo(mixed $address, ?string $name = null): static /** * Set the cc address for the mail message. + * + * @return $this */ public function cc(mixed $address, ?string $name = null): static { @@ -186,6 +214,8 @@ public function cc(mixed $address, ?string $name = null): static /** * Set the bcc address for the mail message. + * + * @return $this */ public function bcc(mixed $address, ?string $name = null): static { @@ -200,6 +230,8 @@ public function bcc(mixed $address, ?string $name = null): static /** * Attach a file to the message. + * + * @return $this */ public function attach(Attachable|Attachment|string $file, array $options = []): static { @@ -220,6 +252,7 @@ public function attach(Attachable|Attachment|string $file, array $options = []): * Attach multiple files to the message. * * @param array $files + * @return $this */ public function attachMany(array $files): static { @@ -236,6 +269,8 @@ public function attachMany(array $files): static /** * Attach in-memory data as an attachment. + * + * @return $this */ public function attachData(string $data, string $name, array $options = []): static { @@ -246,6 +281,8 @@ public function attachData(string $data, string $name, array $options = []): sta /** * Attach a file to the message from storage. + * + * @return $this */ public function attachFromStorage( string $path, @@ -257,6 +294,8 @@ public function attachFromStorage( /** * Attach a file to the message from storage. + * + * @return $this */ public function attachFromStorageDisk( ?string $disk, @@ -279,6 +318,8 @@ public function attachFromStorageDisk( /** * Add a tag header to the message when supported by the underlying transport. + * + * @return $this */ public function tag(string $value): static { @@ -289,6 +330,8 @@ public function tag(string $value): static /** * Add a metadata header to the message when supported by the underlying transport. + * + * @return $this */ public function metadata(string $key, int|string $value): static { @@ -301,6 +344,8 @@ public function metadata(string $key, int|string $value): static * Set the priority of this message. * * The value is an integer where 1 is the highest priority and 5 is the lowest. + * + * @return $this */ public function priority(int $level): static { @@ -362,6 +407,8 @@ public function render(): string /** * Register a callback to be called with the Symfony message instance. + * + * @return $this */ public function withSymfonyMessage(callable $callback): static { diff --git a/src/notifications/src/Messages/SimpleMessage.php b/src/notifications/src/Messages/SimpleMessage.php index f1c284c04a..cea851eaad 100644 --- a/src/notifications/src/Messages/SimpleMessage.php +++ b/src/notifications/src/Messages/SimpleMessage.php @@ -56,6 +56,8 @@ class SimpleMessage /** * Indicate that the notification gives information about a successful operation. + * + * @return $this */ public function success(): static { @@ -66,6 +68,8 @@ public function success(): static /** * Indicate that the notification gives information about an error. + * + * @return $this */ public function error(): static { @@ -76,6 +80,8 @@ public function error(): static /** * Set the "level" of the notification (success, error, etc.). + * + * @return $this */ public function level(string $level): static { @@ -86,6 +92,8 @@ public function level(string $level): static /** * Set the subject of the notification. + * + * @return $this */ public function subject(string $subject): static { @@ -96,6 +104,8 @@ public function subject(string $subject): static /** * Set the greeting of the notification. + * + * @return $this */ public function greeting(string $greeting): static { @@ -106,6 +116,8 @@ public function greeting(string $greeting): static /** * Set the salutation of the notification. + * + * @return $this */ public function salutation(string $salutation): static { @@ -116,6 +128,8 @@ public function salutation(string $salutation): static /** * Add a line of text to the notification. + * + * @return $this */ public function line(mixed $line): static { @@ -124,6 +138,8 @@ public function line(mixed $line): static /** * Add a line of text to the notification if the given condition is true. + * + * @return $this */ public function lineIf(bool $boolean, mixed $line): static { @@ -136,6 +152,8 @@ public function lineIf(bool $boolean, mixed $line): static /** * Add lines of text to the notification. + * + * @return $this */ public function lines(iterable $lines): static { @@ -148,6 +166,8 @@ public function lines(iterable $lines): static /** * Add lines of text to the notification if the given condition is true. + * + * @return $this */ public function linesIf(bool $boolean, iterable $lines): static { @@ -160,6 +180,8 @@ public function linesIf(bool $boolean, iterable $lines): static /** * Add a line of text to the notification. + * + * @return $this */ public function with(mixed $line): static { @@ -192,6 +214,8 @@ protected function formatLine(array|Htmlable|string|null $line): Htmlable|string /** * Configure the "call to action" button. + * + * @return $this */ public function action(string $text, string $url): static { @@ -203,6 +227,8 @@ public function action(string $text, string $url): static /** * Set the name of the mailer that should send the notification. + * + * @return $this */ public function mailer(string $mailer): static { diff --git a/src/notifications/src/Notification.php b/src/notifications/src/Notification.php index 1a8c061d90..9f482bbdf2 100644 --- a/src/notifications/src/Notification.php +++ b/src/notifications/src/Notification.php @@ -30,6 +30,8 @@ public function broadcastOn(): array /** * Set the locale to send this notification in. + * + * @return $this */ public function locale(string $locale): static { diff --git a/src/notifications/src/RoutesNotifications.php b/src/notifications/src/RoutesNotifications.php index 603d8ef921..c32a1bb2ea 100644 --- a/src/notifications/src/RoutesNotifications.php +++ b/src/notifications/src/RoutesNotifications.php @@ -8,9 +8,6 @@ use Hypervel\Contracts\Notifications\Dispatcher; use Hypervel\Support\Str; -/** - * @property string $email - */ trait RoutesNotifications { /** diff --git a/src/object-pool/src/PoolErrorReporter.php b/src/object-pool/src/PoolErrorReporter.php index bbede71368..6ceb7effd5 100644 --- a/src/object-pool/src/PoolErrorReporter.php +++ b/src/object-pool/src/PoolErrorReporter.php @@ -4,9 +4,7 @@ namespace Hypervel\ObjectPool; -use Hypervel\Container\Container; -use Hypervel\Contracts\Debug\ExceptionHandler; -use Swoole\Coroutine\CanceledException; +use Hypervel\Support\FailureReporter; use Throwable; final class PoolErrorReporter @@ -23,26 +21,6 @@ private function __construct() */ public static function report(Throwable $exception): void { - if ($exception instanceof CanceledException) { - return; - } - - try { - $container = Container::getInstance(); - - if ($container->has(ExceptionHandler::class)) { - $container->make(ExceptionHandler::class)->report($exception); - - return; - } - } catch (CanceledException) { - return; - } catch (Throwable) { - } - - try { - error_log((string) $exception); - } catch (Throwable) { - } + FailureReporter::report($exception); } } diff --git a/src/object-pool/src/PoolManager.php b/src/object-pool/src/PoolManager.php index 835698f84f..dadd91da52 100644 --- a/src/object-pool/src/PoolManager.php +++ b/src/object-pool/src/PoolManager.php @@ -6,9 +6,9 @@ use Hypervel\Contracts\ObjectPool\Factory as FactoryContract; use Hypervel\Contracts\ObjectPool\ObjectPool; +use Hypervel\Coroutine\Coroutine; use JsonException; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class PoolManager implements FactoryContract @@ -160,25 +160,18 @@ public function purgeAll(): void $pools = $this->pools; $this->pools = []; $this->definitions = []; - $firstException = null; - $firstCancellation = null; + $exception = null; foreach ($pools as $pool) { try { $pool->close(); - } catch (CanceledException $exception) { - $firstCancellation ??= $exception; - } catch (Throwable $exception) { - $firstException ??= $exception; + } catch (Throwable $throwable) { + $exception = Coroutine::preferCancellation($exception, $throwable); } } - if ($firstCancellation !== null) { - throw $firstCancellation; - } - - if ($firstException !== null) { - throw $firstException; + if ($exception !== null) { + throw $exception; } } diff --git a/src/object-pool/src/PoolRecycler.php b/src/object-pool/src/PoolRecycler.php index eaa3e328f3..7c7f107bb5 100644 --- a/src/object-pool/src/PoolRecycler.php +++ b/src/object-pool/src/PoolRecycler.php @@ -7,9 +7,9 @@ use Hypervel\Contracts\ObjectPool\Factory; use Hypervel\Contracts\ObjectPool\Recycler; use Hypervel\Coordinator\Timer; +use Hypervel\Coroutine\Coroutine; use InvalidArgumentException; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class PoolRecycler implements Recycler @@ -58,9 +58,11 @@ public function start(): void function (): void { try { $this->maintainPools(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + PoolErrorReporter::report($exception); } }, @@ -98,9 +100,11 @@ protected function maintainPools(): void $pool->sweepExpired(); $pool->trimIdle(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + PoolErrorReporter::report(new RuntimeException( "Pool maintenance failed for [{$identity}].", previous: $exception, diff --git a/src/opentelemetry/composer.json b/src/opentelemetry/composer.json index 925e310ed3..e3635188a7 100644 --- a/src/opentelemetry/composer.json +++ b/src/opentelemetry/composer.json @@ -47,6 +47,7 @@ "hypervel/core": "^0.4", "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", + "hypervel/engine": "^0.4", "hypervel/events": "^0.4", "hypervel/foundation": "^0.4", "hypervel/guzzle": "^8.3", @@ -73,7 +74,7 @@ "psr/http-client": "^1.0", "psr/http-message": "^2.0", "psr/log": "^3.0", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "suggest": { "ext-protobuf": "Required when a built-in OTLP exporter uses the http/protobuf protocol (the default).", diff --git a/src/opentelemetry/src/Instrumentation/AbstractInstrumentation.php b/src/opentelemetry/src/Instrumentation/AbstractInstrumentation.php index 05248bda14..11ef070754 100644 --- a/src/opentelemetry/src/Instrumentation/AbstractInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/AbstractInstrumentation.php @@ -8,6 +8,8 @@ use Hypervel\OpenTelemetry\Support\InstrumentationOptions; use OpenTelemetry\API\Metrics\ObserverInterface; use OpenTelemetry\SemConv\Version; +use Swoole\Coroutine\CanceledException; +use Throwable; abstract class AbstractInstrumentation implements Instrumentation { @@ -43,6 +45,17 @@ protected function metricEnabled(string $name): bool return $this->options->metricEnabled($name); } + /** + * Determine whether the throwable is a direct coroutine cancellation. + * + * Ending an operation by direct cancellation is control flow, not a failure. + */ + protected function isDirectCancellation(?Throwable $exception): bool + { + // @phpstan-ignore hypervel.cancellation.exactType (only a direct cancellation is not a failure; a cleanup failure carrying it is recorded as one) + return $exception instanceof CanceledException; + } + /** * Return the semantic conventions schema URL for Hypervel instrumentation scopes. * diff --git a/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php b/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php index 5267073d2f..45959d180a 100644 --- a/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php @@ -161,15 +161,15 @@ protected function record( ErrorAttributes::ERROR_TYPE => $exception === null ? null : $exception::class, 'hypervel.cache.failed_store' => $failedStore, ], static fn (mixed $value): bool => $value !== null); + + // Counted first: the application key resolver may be canceled. + $this->operations?->add($count, $attributes); $resolvedKey = null; if ($span !== null && $key !== null && $this->captureKey) { - // The application resolver may cancel, and cancellation must emit no completion telemetry. $resolvedKey = $this->key($key, $store); } - $this->operations?->add($count, $attributes); - if ($span !== null) { if ($resolvedKey !== null) { $attributes['hypervel.cache.key'] = $resolvedKey; diff --git a/src/opentelemetry/src/Instrumentation/ConsoleInstrumentation.php b/src/opentelemetry/src/Instrumentation/ConsoleInstrumentation.php index 30e01fb852..5cd147da9a 100644 --- a/src/opentelemetry/src/Instrumentation/ConsoleInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/ConsoleInstrumentation.php @@ -24,7 +24,6 @@ use OpenTelemetry\API\Trace\TracerProviderInterface; use OpenTelemetry\Context\Context; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; -use Swoole\Coroutine\CanceledException; class ConsoleInstrumentation extends AbstractInstrumentation { @@ -176,10 +175,6 @@ protected function start(BeforeHandle $event): void */ protected function finish(AfterExecute $event): void { - if ($event->throwable instanceof CanceledException) { - return; - } - $state = $this->takeState($event->command); if ($state === null) { @@ -187,25 +182,30 @@ protected function finish(AfterExecute $event): void } $finishedAt = $this->clock->now(); - $failed = $event->throwable !== null || $event->exitCode !== 0; - $result = $failed ? 'failure' : 'success'; - $attributes = $state->attributes + [ - 'result' => $result, - self::EXIT_CODE_ATTRIBUTE => $event->exitCode, - ]; - - if ($event->throwable !== null) { - $attributes[ErrorAttributes::ERROR_TYPE] = $event->throwable::class; + $exception = null; + $failed = false; + $outcome = []; + + // A directly canceled command produced no result or exit code. + if (! $this->isDirectCancellation($event->throwable)) { + $exception = $event->throwable; + $failed = $exception !== null || $event->exitCode !== 0; + $outcome = [ + 'result' => $failed ? 'failure' : 'success', + self::EXIT_CODE_ATTRIBUTE => $event->exitCode, + ]; + + if ($exception !== null) { + $outcome[ErrorAttributes::ERROR_TYPE] = $exception::class; + } } try { if ($state->span?->isRecording()) { - $state->span->setAttribute('result', $result); - $state->span->setAttribute(self::EXIT_CODE_ATTRIBUTE, $event->exitCode); + $state->span->setAttributes($outcome); - if ($event->throwable !== null) { - $state->span->recordException($event->throwable); - $state->span->setAttribute(ErrorAttributes::ERROR_TYPE, $event->throwable::class); + if ($exception !== null) { + $state->span->recordException($exception); } if ($failed) { @@ -213,9 +213,9 @@ protected function finish(AfterExecute $event): void } } - if ($event->throwable !== null && $state->span !== null) { + if ($exception !== null && $state->span !== null) { $this->exceptionContexts->associate( - $event->throwable, + $exception, $state->context, $this->origins->resolve($state->context, $this->identity), ); @@ -223,7 +223,7 @@ protected function finish(AfterExecute $event): void $this->duration?->record( ($finishedAt - $state->startedAt) / ClockInterface::NANOS_PER_SECOND, - $attributes, + $state->attributes + $outcome, $state->context, ); } finally { diff --git a/src/opentelemetry/src/Instrumentation/GrpcInstrumentation.php b/src/opentelemetry/src/Instrumentation/GrpcInstrumentation.php index 64cde985be..b9da69f38e 100644 --- a/src/opentelemetry/src/Instrumentation/GrpcInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/GrpcInstrumentation.php @@ -31,7 +31,6 @@ use OpenTelemetry\SemConv\Attributes\ErrorAttributes; use OpenTelemetry\SemConv\Attributes\RpcAttributes; use OpenTelemetry\SemConv\Attributes\ServerAttributes; -use Swoole\Coroutine\CanceledException; use Throwable; class GrpcInstrumentation extends AbstractInstrumentation implements GrpcOperationObserver @@ -147,7 +146,7 @@ public function finished( mixed $token, GrpcOperationResult $result, ): void { - if ($token === null || $result->exception instanceof CanceledException) { + if ($token === null) { return; } @@ -195,8 +194,6 @@ protected function startClient(ClientGrpcOperation $operation): GrpcTelemetrySta $metadata = $operation->metadata(); $this->propagator->inject($metadata, $this->metadataSetter, $context); $operation->withMetadata($metadata); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { $this->complete( $operation, @@ -279,11 +276,13 @@ protected function complete( $statusName = null; $errorType = null; $metricAttributes = $state->metricAttributes; + // A directly canceled call did not fail; any real final status still applies. + $exception = $this->isDirectCancellation($result->exception) ? null : $result->exception; if ($spanRecording || $duration !== null) { $status = $result->status?->code(); $statusName = $status === null ? null : $this->statusName($status); - $errorType = $this->errorType($operation, $status, $statusName, $result->exception); + $errorType = $this->errorType($operation, $status, $statusName, $exception); if ($statusName !== null) { $metricAttributes[RpcAttributes::RPC_STATUS_CODE] = $statusName; @@ -304,8 +303,8 @@ protected function complete( $state->span->setAttribute(self::ATTEMPT_COUNT_ATTRIBUTE, $result->attemptCount); } - if ($result->exception !== null) { - $state->span->recordException($result->exception); + if ($exception !== null) { + $state->span->recordException($exception); } if ($errorType !== null) { @@ -314,9 +313,9 @@ protected function complete( } } - if ($operation instanceof ClientGrpcOperation && $result->exception !== null && $state->span !== null) { + if ($operation instanceof ClientGrpcOperation && $exception !== null && $state->span !== null) { $this->exceptionContexts->associate( - $result->exception, + $exception, $state->context, $this->origins->resolve($state->context, $this->identity), ); diff --git a/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php b/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php index b897ca2564..48bb6c37ae 100644 --- a/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php @@ -37,7 +37,6 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; -use Swoole\Coroutine\CanceledException; use Throwable; use UnexpectedValueException; @@ -188,8 +187,6 @@ protected function middleware(): Closure } $promise = $handler($request, $options); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { $this->completeFailure( $exception, @@ -225,10 +222,6 @@ function (mixed $reason) use ( $startedAt, $requestAttributes, ): PromiseInterface { - if ($reason instanceof CanceledException) { - throw $reason; - } - if ($reason instanceof Throwable) { $this->completeFailure( $reason, @@ -422,7 +415,7 @@ protected function completeResponse( } /** - * Complete a failed physical request. + * Complete a failed or canceled physical request. * * @param array $requestAttributes */ @@ -433,6 +426,13 @@ protected function completeFailure( int $startedAt, array $requestAttributes, ): void { + // HTTP client semantic conventions leave a canceled request's status unset and omit error.type. + if ($this->isDirectCancellation($exception)) { + $this->complete($span, $context, $startedAt, $requestAttributes); + + return; + } + if ($span !== null) { $this->exceptionContexts->associate( $exception, diff --git a/src/opentelemetry/src/Instrumentation/HttpServerInstrumentation.php b/src/opentelemetry/src/Instrumentation/HttpServerInstrumentation.php index b2639e0ee5..61055afe0e 100644 --- a/src/opentelemetry/src/Instrumentation/HttpServerInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/HttpServerInstrumentation.php @@ -9,6 +9,7 @@ use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request; use Hypervel\Http\Response as HypervelResponse; +use Hypervel\HttpServer\Events\RequestCanceled; use Hypervel\HttpServer\Events\RequestHandled; use Hypervel\HttpServer\Events\RequestReceived; use Hypervel\HttpServer\Events\ResponseSent; @@ -37,7 +38,6 @@ use OpenTelemetry\SemConv\Attributes\UrlAttributes; use OpenTelemetry\SemConv\Attributes\UserAgentAttributes; use OpenTelemetry\SemConv\Metrics\HttpMetrics; -use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -180,7 +180,17 @@ protected function registerInstrumentation(): void $this->requestReceived($event); }); $this->events->listen(ResponseSent::class, function (ResponseSent $event): void { - $this->responseSent($event); + $this->complete($event->request, $event->response, $event->exception); + }); + $this->events->listen(RequestCanceled::class, function (RequestCanceled $event): void { + // A sent response keeps its outcome; otherwise only a cleanup failure carrying the cancellation is one. + $this->complete( + $event->request, + $event->response, + $event->exception ?? ($event->response !== null || $this->isDirectCancellation($event->cancellation) + ? null + : $event->cancellation), + ); }); if ($this->tracer !== null && ! $this->responsePropagator instanceof NoopResponsePropagator) { @@ -275,8 +285,7 @@ protected function requestHandled(RequestHandled $event): void { $state = RequestTelemetryState::current(); - if ($event->exception instanceof CanceledException - || $event->response === null + if ($event->response === null || $state === null || $state->completed || $state->request !== $event->request @@ -290,13 +299,15 @@ protected function requestHandled(RequestHandled $event): void } /** - * Complete telemetry at the response transport boundary. + * Complete telemetry at the response transport boundary, or where cancellation stopped the request. + * + * The response is null when none was sent; the exception is the lifecycle failure, if any. */ - protected function responseSent(ResponseSent $event): void + protected function complete(?Request $request, ?Response $response, ?Throwable $exception): void { $state = RequestTelemetryState::current(); - if ($state === null || $state->completed || $state->request !== $event->request) { + if ($state === null || $state->completed || $state->request !== $request) { return; } @@ -305,14 +316,12 @@ protected function responseSent(ResponseSent $event): void $finishedAt = $state->span === null && $this->duration === null ? 0 : $this->clock->now(); - $response = null; $errorType = null; $completionAttributes = []; if ($recordingSpan || $this->duration !== null) { - $response = $event->response; $statusCode = $response?->getStatusCode(); - $exception = $event->exception ?? $this->responseException($response); + $exception ??= $this->responseException($response); $errorType = $exception !== null ? $exception::class : ($statusCode !== null && $statusCode >= 500 ? (string) $statusCode : null); @@ -336,10 +345,6 @@ protected function responseSent(ResponseSent $event): void $state->span->setAttributes($this->responseTraceAttributes($response)); } - if ($this->userContext) { - $state->span->setAttributes($this->userContexts->resolve($state)); - } - if ($errorType !== null) { $state->span->setStatus(StatusCode::STATUS_ERROR); } @@ -352,6 +357,12 @@ protected function responseSent(ResponseSent $event): void $state->context, ); } + + // Resolved last: an application user resolver may fail or be canceled + // and must not drop the outcome recorded above. + if ($recordingSpan && $this->userContext) { + $state->span->setAttributes($this->userContexts->resolve($state)); + } } finally { if ($state->activeRequestRecorded) { $this->activeRequests?->add(-1, $state->activeRequestAttributes, $state->context); diff --git a/src/opentelemetry/src/Instrumentation/QueueInstrumentation.php b/src/opentelemetry/src/Instrumentation/QueueInstrumentation.php index ff216553dd..4ff79a52ca 100644 --- a/src/opentelemetry/src/Instrumentation/QueueInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/QueueInstrumentation.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\Job; +use Hypervel\Coroutine\Coroutine; use Hypervel\OpenTelemetry\Support\ExceptionContextRegistry; use Hypervel\OpenTelemetry\Support\LogContextScopeFactory; use Hypervel\OpenTelemetry\Support\OperationOrigin; @@ -20,6 +21,7 @@ use Hypervel\Queue\Events\JobPayloadFinalizing; use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\Events\JobQueued; +use Hypervel\Queue\Events\JobQueueingCanceled; use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Queue\Events\JobTimedOut; use Hypervel\Queue\Events\OutboxBatchDelivered; @@ -43,7 +45,6 @@ use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter; use OpenTelemetry\Context\Propagation\TextMapPropagatorInterface; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; -use Swoole\Coroutine\CanceledException; use Throwable; use WeakMap; @@ -194,7 +195,15 @@ protected function registerInstrumentation(): void $this->finishProducer($event->payload); }); $this->events->listen(JobQueueingFailed::class, function (JobQueueingFailed $event): void { - $this->finishProducer($event->payload, $event->exception); + $this->finishProducer($event->payload, $event->exception, $event->sendAttempted); + }); + $this->events->listen(JobQueueingCanceled::class, function (JobQueueingCanceled $event): void { + // A queued job keeps its success; otherwise only a cleanup failure carrying the cancellation is one. + $this->finishProducer( + $event->payload, + $event->exception ?? ($event->queued || $this->isDirectCancellation($event->cancellation) ? null : $event->cancellation), + $event->sendAttempted, + ); }); } @@ -241,7 +250,7 @@ protected function registerMetrics(): void $this->sentMessages = $meter->createCounter( self::SENT_MESSAGES_METRIC, '{message}', - 'The number of messages sent to a messaging system.', + 'The number of messages the producer attempted to send.', ); } @@ -394,18 +403,14 @@ protected function finalizePayload(JobPayloadFinalizing $event): void strlen($finalPayload), ); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($span !== null) { - $span->recordException($exception); - $span->setStatus(StatusCode::STATUS_ERROR); - $this->exceptionContexts->associate( - $exception, - $context, - $this->origins->resolve($context, $this->identity), + // The job never reached the queue backend, and no later event can find this state. + if ($span !== null || $this->sendDuration !== null) { + $this->completeProducer( + new QueueProducerState($startedAt, $context, $span, $attributes, $uuid), + $this->isDirectCancellation($exception) ? null : $exception, + sendAttempted: false, ); - $span->end(); } throw $exception; @@ -422,7 +427,7 @@ protected function finalizePayload(JobPayloadFinalizing $event): void /** * Complete persistent producer telemetry. */ - protected function finishProducer(string $payload, ?Throwable $exception = null): void + protected function finishProducer(string $payload, ?Throwable $exception = null, bool $sendAttempted = true): void { $store = QueueProducerStateStore::current(); $state = $store->take($payload); @@ -435,6 +440,14 @@ protected function finishProducer(string $payload, ?Throwable $exception = null) return; } + $this->completeProducer($state, $exception, $sendAttempted); + } + + /** + * Complete producer telemetry from its started state. + */ + protected function completeProducer(QueueProducerState $state, ?Throwable $exception, bool $sendAttempted): void + { $finishedAt = $state->span === null && $this->sendDuration === null ? 0 : $this->clock->now(); @@ -455,7 +468,10 @@ protected function finishProducer(string $payload, ?Throwable $exception = null) ); } - $this->sentMessages?->add(1, $attributes, $state->context); + if ($sendAttempted) { + $this->sentMessages?->add(1, $attributes, $state->context); + } + $this->sendDuration?->record( ($finishedAt - $state->startedAt) / ClockInterface::NANOS_PER_SECOND, $attributes, @@ -644,7 +660,8 @@ protected function finishConsumer(JobAttempted $event): void $state->completed = true; unset($this->consumerStates[$event->job]); - if ($state->errorType === null && $event->exception !== null) { + // A directly canceled attempt did not fail. + if ($state->errorType === null && $event->exception !== null && ! $this->isDirectCancellation($event->exception)) { $state->exception = $event->exception; $state->errorType = $event->exception::class; } @@ -714,9 +731,11 @@ function (ObserverInterface $observer) use ($targets): void { foreach ($queues as $queue) { try { $size = $this->queues->connection($connection)->size($queue); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + self::logError('OpenTelemetry queue depth collection failed.', [ 'exception' => $exception, 'connection' => $connection, diff --git a/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php b/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php index 226f217a98..6ee805c5e5 100644 --- a/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php @@ -164,6 +164,7 @@ protected function recordCommand( $elapsedMilliseconds * ClockInterface::NANOS_PER_MILLISECOND, ); } + $operation = strtoupper($command); $attributes = $this->attributes( $operation, @@ -183,42 +184,43 @@ protected function recordCommand( ->setAttributes($attributes) ->startSpan(); $context = $span->storeInContext($parent); + } - if ($span->isRecording()) { - if ($this->queryText) { - $queryText = $this->queryText( - $command, - $operation, - $parameters, - $connection, - ); - - if ($queryText !== null) { - $span->setAttribute(DbAttributes::DB_QUERY_TEXT, $queryText); - } - } - - if ($exception !== null) { + try { + if ($span !== null && $exception !== null) { + if ($span->isRecording()) { $span->recordException($exception); $span->setStatus(StatusCode::STATUS_ERROR); } - } - if ($exception !== null) { $this->exceptionContexts->associate( $exception, $context, $this->origins->resolve($context, $this->identity), ); } - } - $this->duration?->record( - $elapsedMilliseconds / ClockInterface::MILLIS_PER_SECOND, - $attributes, - $context, - ); - $span?->end($finishedAt); + // Resolved last: an application resolver may be canceled and must not drop the outcome recorded above. + if ($this->queryText && $span?->isRecording()) { + $queryText = $this->queryText( + $command, + $operation, + $parameters, + $connection, + ); + + if ($queryText !== null) { + $span->setAttribute(DbAttributes::DB_QUERY_TEXT, $queryText); + } + } + } finally { + $this->duration?->record( + $elapsedMilliseconds / ClockInterface::MILLIS_PER_SECOND, + $attributes, + $context, + ); + $span?->end($finishedAt); + } } /** diff --git a/src/opentelemetry/src/Instrumentation/SchedulerInstrumentation.php b/src/opentelemetry/src/Instrumentation/SchedulerInstrumentation.php index be572df1e3..b2591aeede 100644 --- a/src/opentelemetry/src/Instrumentation/SchedulerInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/SchedulerInstrumentation.php @@ -4,6 +4,7 @@ namespace Hypervel\OpenTelemetry\Instrumentation; +use Hypervel\Console\Events\ScheduledTaskCanceled; use Hypervel\Console\Events\ScheduledTaskFailed; use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Console\Events\ScheduledTaskStarting; @@ -26,7 +27,7 @@ use OpenTelemetry\API\Trace\TracerProviderInterface; use OpenTelemetry\Context\Context; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; -use Swoole\Coroutine\CanceledException; +use Throwable; class SchedulerInstrumentation extends AbstractInstrumentation { @@ -116,10 +117,13 @@ protected function registerInstrumentation(): void $this->start($event); }); $this->events->listen(ScheduledTaskFinished::class, function (ScheduledTaskFinished $event): void { - $this->finish($event); + $this->finish($event->task); }); $this->events->listen(ScheduledTaskFailed::class, function (ScheduledTaskFailed $event): void { - $this->fail($event); + $this->fail($event->task, $event->exception); + }); + $this->events->listen(ScheduledTaskCanceled::class, function (ScheduledTaskCanceled $event): void { + $this->canceled($event); }); } @@ -171,9 +175,9 @@ protected function start(ScheduledTaskStarting $event): void /** * Complete a successful or non-zero scheduled task. */ - protected function finish(ScheduledTaskFinished $event): void + protected function finish(Event $task): void { - $state = $this->takeState($event->task); + $state = $this->takeState($task); if ($state === null) { return; @@ -182,7 +186,7 @@ protected function finish(ScheduledTaskFinished $event): void $finishedAt = $state->span === null && $this->duration === null ? 0 : $this->clock->now(); - $exitCode = $event->task->exitCode(); + $exitCode = $task->exitCode(); $result = $exitCode === null || $exitCode === 0 ? 'success' : 'failure'; $attributes = $state->attributes + ['result' => $result]; @@ -208,13 +212,9 @@ protected function finish(ScheduledTaskFinished $event): void /** * Complete a failed scheduled task. */ - protected function fail(ScheduledTaskFailed $event): void + protected function fail(Event $task, Throwable $exception): void { - if ($event->exception instanceof CanceledException) { - return; - } - - $state = $this->takeState($event->task); + $state = $this->takeState($task); if ($state === null) { return; @@ -227,15 +227,15 @@ protected function fail(ScheduledTaskFailed $event): void try { if ($state->span?->isRecording()) { - $state->span->recordException($event->exception); + $state->span->recordException($exception); $state->span->setAttribute('result', 'failure'); - $state->span->setAttribute(ErrorAttributes::ERROR_TYPE, $event->exception::class); + $state->span->setAttribute(ErrorAttributes::ERROR_TYPE, $exception::class); $state->span->setStatus(StatusCode::STATUS_ERROR); } if ($state->span !== null) { $this->exceptionContexts->associate( - $event->exception, + $exception, $state->context, $this->origins->resolve($state->context, $this->identity), ); @@ -247,6 +247,47 @@ protected function fail(ScheduledTaskFailed $event): void } } + /** + * Complete a scheduled task that cancellation stopped before its outcome was published. + */ + protected function canceled(ScheduledTaskCanceled $event): void + { + if ($event->exception !== null) { + $this->fail($event->task, $event->exception); + + return; + } + + // A task that finished running keeps its outcome; the cancellation came from a listener. + if ($event->runtime !== null) { + $this->finish($event->task); + + return; + } + + if (! $this->isDirectCancellation($event->cancellation)) { + $this->fail($event->task, $event->cancellation); + + return; + } + + $state = $this->takeState($event->task); + + if ($state === null) { + return; + } + + $finishedAt = $state->span === null && $this->duration === null + ? 0 + : $this->clock->now(); + + try { + $this->recordMetrics($state, $finishedAt, $state->attributes); + } finally { + $this->close($state, $finishedAt); + } + } + /** * Record enabled scheduled-task metrics. * diff --git a/src/opentelemetry/src/Instrumentation/ScoutInstrumentation.php b/src/opentelemetry/src/Instrumentation/ScoutInstrumentation.php index 84fc6c3a33..a1dcab54fb 100644 --- a/src/opentelemetry/src/Instrumentation/ScoutInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/ScoutInstrumentation.php @@ -20,7 +20,6 @@ use OpenTelemetry\SemConv\Attributes\DbAttributes; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; use OpenTelemetry\SemConv\Metrics\DbMetrics; -use Swoole\Coroutine\CanceledException; use Throwable; class ScoutInstrumentation extends AbstractInstrumentation implements EngineOperationObserver @@ -130,12 +129,14 @@ public function finished( mixed $token, ?Throwable $exception ): void { - if ($exception instanceof CanceledException) { - return; - } - /** @var ScoutTelemetryState $state */ $state = $token; + + // A directly canceled operation did not fail. + if ($this->isDirectCancellation($exception)) { + $exception = null; + } + $finishedAt = $this->clock->now(); $metricAttributes = $state->metricAttributes; diff --git a/src/opentelemetry/src/Instrumentation/ViewInstrumentation.php b/src/opentelemetry/src/Instrumentation/ViewInstrumentation.php index b8b9267590..0798a52f52 100644 --- a/src/opentelemetry/src/Instrumentation/ViewInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/ViewInstrumentation.php @@ -18,7 +18,6 @@ use OpenTelemetry\API\Trace\TracerProviderInterface; use OpenTelemetry\Context\Context; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; -use Swoole\Coroutine\CanceledException; use Throwable; class ViewInstrumentation extends AbstractInstrumentation @@ -140,11 +139,6 @@ protected function start(ViewContract $view): void */ protected function finish(ViewContract $view, ?Throwable $exception): void { - // Direct notifyRendered() callers can supply cancellation; the framework render path skips this callback. - if ($exception instanceof CanceledException) { - return; - } - $state = $this->takeState($view); if ($state === null) { @@ -152,27 +146,32 @@ protected function finish(ViewContract $view, ?Throwable $exception): void } $finishedAt = $this->clock->now(); - $result = $exception === null ? 'success' : 'failure'; - $attributes = $state->attributes + ['result' => $result]; + $failure = null; + $outcome = []; - if ($exception !== null) { - $attributes[ErrorAttributes::ERROR_TYPE] = $exception::class; + // A directly canceled render produced no result. + if (! $this->isDirectCancellation($exception)) { + $failure = $exception; + $outcome['result'] = $failure === null ? 'success' : 'failure'; + + if ($failure !== null) { + $outcome[ErrorAttributes::ERROR_TYPE] = $failure::class; + } } try { if ($state->span?->isRecording()) { - $state->span->setAttribute('result', $result); + $state->span->setAttributes($outcome); - if ($exception !== null) { - $state->span->recordException($exception); - $state->span->setAttribute(ErrorAttributes::ERROR_TYPE, $exception::class); + if ($failure !== null) { + $state->span->recordException($failure); $state->span->setStatus(StatusCode::STATUS_ERROR); } } $this->duration?->record( ($finishedAt - $state->startedAt) / ClockInterface::NANOS_PER_SECOND, - $attributes, + $state->attributes + $outcome, $state->context, ); } finally { diff --git a/src/opentelemetry/src/Instrumentation/WebSocketInstrumentation.php b/src/opentelemetry/src/Instrumentation/WebSocketInstrumentation.php index f630b48f1f..5c2245342b 100644 --- a/src/opentelemetry/src/Instrumentation/WebSocketInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/WebSocketInstrumentation.php @@ -24,7 +24,6 @@ use OpenTelemetry\API\Trace\TracerProviderInterface; use OpenTelemetry\Context\Context; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; -use Swoole\Coroutine\CanceledException; use Swoole\WebSocket\Frame; class WebSocketInstrumentation extends AbstractInstrumentation @@ -190,10 +189,6 @@ protected function start(MessageReceived $event): void */ protected function finish(MessageHandled $event): void { - if ($event->exception instanceof CanceledException) { - return; - } - $state = $this->takeState($event->frame); if ($state === null && $this->messages === null) { @@ -203,26 +198,33 @@ protected function finish(MessageHandled $event): void $finishedAt = $state === null ? 0 : $this->clock->now(); $spanRecording = $state?->span?->isRecording() === true; $attributes = $state->attributes ?? []; + // A directly canceled message produced no result. + $canceled = $this->isDirectCancellation($event->exception); + $exception = $canceled ? null : $event->exception; if ($spanRecording || ($state !== null && $this->duration !== null) || $this->messages !== null) { if ($attributes === []) { $attributes = $this->messageAttributes($event->frame, $event->server); } - $attributes['result'] = $event->exception === null ? 'success' : 'failure'; + if (! $canceled) { + $attributes['result'] = $exception === null ? 'success' : 'failure'; + } - if ($event->exception !== null) { - $attributes[ErrorAttributes::ERROR_TYPE] = $event->exception::class; + if ($exception !== null) { + $attributes[ErrorAttributes::ERROR_TYPE] = $exception::class; } } try { if ($spanRecording) { - $state->span?->setAttribute('result', $attributes['result']); + if (! $canceled) { + $state->span?->setAttribute('result', $attributes['result']); + } - if ($event->exception !== null) { - $state->span?->recordException($event->exception); - $state->span?->setAttribute(ErrorAttributes::ERROR_TYPE, $event->exception::class); + if ($exception !== null) { + $state->span?->recordException($exception); + $state->span?->setAttribute(ErrorAttributes::ERROR_TYPE, $exception::class); $state->span?->setStatus(StatusCode::STATUS_ERROR); } } diff --git a/src/opentelemetry/src/OpenTelemetryManager.php b/src/opentelemetry/src/OpenTelemetryManager.php index 04e1fbdd4e..1b65f96c06 100644 --- a/src/opentelemetry/src/OpenTelemetryManager.php +++ b/src/opentelemetry/src/OpenTelemetryManager.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Container\Container; +use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; use Hypervel\OpenTelemetry\Contracts\ExporterFactory; use Hypervel\OpenTelemetry\Contracts\Instrumentation; @@ -148,13 +149,11 @@ public function trace(string $name, Closure $callback, iterable $attributes = [] ->setAttributes($attributes) ->startSpan(); $scope = $span->activate(); - $cancelled = false; try { return $callback($span); + // @phpstan-ignore hypervel.cancellation.exactType (only a direct cancellation is not a failure; a cleanup failure carrying it is recorded as one) } catch (CanceledException $exception) { - $cancelled = true; - throw $exception; } catch (Throwable $exception) { $context = Context::getCurrent(); @@ -169,10 +168,7 @@ public function trace(string $name, Closure $callback, iterable $attributes = [] throw $exception; } finally { $scope->detach(); - - if (! $cancelled) { - $span->end(); - } + $span->end(); } } @@ -362,7 +358,7 @@ public function shutdown(): bool } } catch (Throwable $throwable) { $success = false; - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } } finally { diff --git a/src/opentelemetry/src/Support/ExportScheduler.php b/src/opentelemetry/src/Support/ExportScheduler.php index 6f0b2c53b7..8ab10843a2 100644 --- a/src/opentelemetry/src/Support/ExportScheduler.php +++ b/src/opentelemetry/src/Support/ExportScheduler.php @@ -5,9 +5,9 @@ namespace Hypervel\OpenTelemetry\Support; use Hypervel\Coordinator\Timer; +use Hypervel\Coroutine\Coroutine; use Hypervel\OpenTelemetry\OpenTelemetryManager; use OpenTelemetry\API\Behavior\LogsMessagesTrait; -use Swoole\Coroutine\CanceledException; use Throwable; class ExportScheduler @@ -121,9 +121,11 @@ protected function handleTick(bool $isClosing): ?string if (! $this->manager->shutdown()) { self::logError('OpenTelemetry provider shutdown did not complete successfully.'); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + self::logError('OpenTelemetry provider shutdown failed.', ['exception' => $exception]); } @@ -150,9 +152,11 @@ protected function handleTick(bool $isClosing): ?string if ($this->manager->flushSignals($dueSignals) === false) { self::logError('OpenTelemetry periodic export did not complete successfully.'); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + self::logError('OpenTelemetry periodic export failed.', ['exception' => $exception]); } finally { if ($generation === $this->generation) { diff --git a/src/pagination/src/AbstractCursorPaginator.php b/src/pagination/src/AbstractCursorPaginator.php index d2192cee61..c7cc2fe984 100644 --- a/src/pagination/src/AbstractCursorPaginator.php +++ b/src/pagination/src/AbstractCursorPaginator.php @@ -28,6 +28,7 @@ * @template TKey of array-key * * @template-covariant TValue + * @template-covariant TCollection of Collection = Collection the stored collection, kept as given * * @mixin Collection */ @@ -362,7 +363,10 @@ protected function buildFragment(): string /** * Load a set of relationships onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-eager-loads $relations * @return $this */ public function loadMorph(string $relation, array $relations): static @@ -376,7 +380,10 @@ public function loadMorph(string $relation, array $relations): static /** * Load a set of relationship counts onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-aggregates $relations * @return $this */ public function loadMorphCount(string $relation, array $relations): static @@ -405,7 +412,7 @@ public function items(): array * @param callable(TValue, TKey): TThroughValue $callback * @return $this * - * @phpstan-this-out static + * @phpstan-this-out static> */ public function through(callable $callback): static { @@ -556,7 +563,7 @@ public function count(): int /** * Get the paginator's underlying collection. * - * @return Collection + * @return Collection&TCollection */ public function getCollection(): Collection { @@ -568,11 +575,12 @@ public function getCollection(): Collection * * @template TSetKey of array-key * @template TSetValue + * @template TSetCollection of Collection * - * @param Collection $collection + * @param Collection&TSetCollection $collection * @return $this * - * @phpstan-this-out static + * @phpstan-this-out static */ public function setCollection(Collection $collection): static { diff --git a/src/pagination/src/AbstractPaginator.php b/src/pagination/src/AbstractPaginator.php index 72f003aada..e142ceb69c 100644 --- a/src/pagination/src/AbstractPaginator.php +++ b/src/pagination/src/AbstractPaginator.php @@ -23,6 +23,7 @@ * @template TKey of array-key * * @template-covariant TValue + * @template-covariant TCollection of Collection = Collection the stored collection, kept as given * * @mixin Collection */ @@ -286,7 +287,10 @@ protected function buildFragment(): string /** * Load a set of relationships onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-eager-loads $relations * @return $this */ public function loadMorph(string $relation, array $relations): static @@ -300,7 +304,10 @@ public function loadMorph(string $relation, array $relations): static /** * Load a set of relationship counts onto the mixed relationship collection. * + * @phpstan-template TRelations of array + * * @param array): mixed)|string>|string> $relations + * @phpstan-param morph-aggregates $relations * @return $this */ public function loadMorphCount(string $relation, array $relations): static @@ -345,7 +352,7 @@ public function lastItem(): ?int * @param callable(TValue, TKey): TMapValue $callback * @return $this * - * @phpstan-this-out static + * @phpstan-this-out static> */ public function through(callable $callback): static { @@ -630,7 +637,7 @@ public function count(): int /** * Get the paginator's underlying collection. * - * @return Collection + * @return Collection&TCollection */ public function getCollection(): Collection { @@ -642,11 +649,12 @@ public function getCollection(): Collection * * @template TSetKey of array-key * @template TSetValue + * @template TSetCollection of Collection * - * @param Collection $collection + * @param Collection&TSetCollection $collection * @return $this * - * @phpstan-this-out static + * @phpstan-this-out static */ public function setCollection(Collection $collection): static { diff --git a/src/pagination/src/CursorPaginator.php b/src/pagination/src/CursorPaginator.php index f88155c977..a1dab56953 100644 --- a/src/pagination/src/CursorPaginator.php +++ b/src/pagination/src/CursorPaginator.php @@ -20,8 +20,9 @@ * @template TKey of array-key * * @template-covariant TValue + * @template-covariant TCollection of Collection = Collection * - * @extends AbstractCursorPaginator + * @extends AbstractCursorPaginator * * @implements Arrayable> * @implements ArrayAccess @@ -33,7 +34,9 @@ class CursorPaginator extends AbstractCursorPaginator implements Arrayable, Arra /** * Create a new paginator instance. * - * @param null|Arrayable>|Collection|iterable $items + * The items are renumbered, so their keys do not decide TKey. + * + * @param null|Arrayable>|Collection|iterable $items * @param array $options (path, query, fragment, cursorName, parameters) */ public function __construct(mixed $items, int $perPage, ?Cursor $cursor = null, array $options = []) @@ -54,7 +57,7 @@ public function __construct(mixed $items, int $perPage, ?Cursor $cursor = null, /** * Set the items for the paginator. * - * @param null|Arrayable>|Collection|iterable $items + * @param null|Arrayable>|Collection|iterable $items */ protected function setItems(mixed $items): void { diff --git a/src/pagination/src/LengthAwarePaginator.php b/src/pagination/src/LengthAwarePaginator.php index b879f7d68a..b3cf2fc909 100644 --- a/src/pagination/src/LengthAwarePaginator.php +++ b/src/pagination/src/LengthAwarePaginator.php @@ -20,8 +20,9 @@ * @template TKey of array-key * * @template-covariant TValue + * @template-covariant TCollection of Collection = Collection * - * @extends AbstractPaginator + * @extends AbstractPaginator * * @implements Arrayable> * @implements ArrayAccess @@ -200,7 +201,7 @@ public function toArray(): array 'from' => $this->firstItem(), 'last_page' => $this->lastPage(), 'last_page_url' => $this->url($this->lastPage()), - 'links' => $this->linkCollection()->toArray(), + 'links' => $this->linkCollection()->all(), 'next_page_url' => $this->nextPageUrl(), 'path' => $this->path(), 'per_page' => $this->perPage(), diff --git a/src/pagination/src/Paginator.php b/src/pagination/src/Paginator.php index 363709488e..a41a760195 100644 --- a/src/pagination/src/Paginator.php +++ b/src/pagination/src/Paginator.php @@ -20,8 +20,9 @@ * @template TKey of array-key * * @template-covariant TValue + * @template-covariant TCollection of Collection = Collection * - * @extends AbstractPaginator + * @extends AbstractPaginator * * @implements Arrayable> * @implements ArrayAccess diff --git a/src/passkeys/composer.json b/src/passkeys/composer.json index 3f32f16d2e..aa4be419cd 100644 --- a/src/passkeys/composer.json +++ b/src/passkeys/composer.json @@ -45,8 +45,8 @@ "nesbot/carbon": "^3.13.1", "paragonie/constant_time_encoding": "^3.1", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/serializer": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/serializer": "^8.1.11", "web-auth/cose-lib": "^4.8.1", "web-auth/webauthn-lib": "^5.3" }, diff --git a/src/passkeys/src/Passkey.php b/src/passkeys/src/Passkey.php index b9c7318e73..170b3dd169 100644 --- a/src/passkeys/src/Passkey.php +++ b/src/passkeys/src/Passkey.php @@ -34,6 +34,7 @@ class Passkey extends Model * * @var list */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Fillable] would merge with a subclass's $fillable instead of letting it replace this list) protected array $fillable = [ 'name', 'credential_id', @@ -45,6 +46,7 @@ class Passkey extends Model * * @var list */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Appends] would merge with a subclass's $appends instead of letting it replace this list) protected array $appends = [ 'authenticator', ]; @@ -74,6 +76,8 @@ public function user(): MorphTo /** * Get the authenticator name based on the AAGUID. + * + * @return Attribute */ protected function authenticator(): Attribute { diff --git a/src/passport/composer.json b/src/passport/composer.json index 9f1294c96a..711fea6b08 100644 --- a/src/passport/composer.json +++ b/src/passport/composer.json @@ -52,6 +52,7 @@ "hypervel/routing": "^0.4", "hypervel/session": "^0.4", "hypervel/support": "^0.4", + "hypervel/validation": "^0.4", "lcobucci/jwt": "^5.6", "league/oauth2-server": "^9.4", "nesbot/carbon": "^3.13.1", @@ -59,7 +60,7 @@ "psr/clock": "^1.0", "psr/http-message": "^2.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/psr-http-message-bridge": "^8.1" }, "autoload": { diff --git a/src/passport/src/AuthCode.php b/src/passport/src/AuthCode.php index 216751b44f..7f0ff05462 100644 --- a/src/passport/src/AuthCode.php +++ b/src/passport/src/AuthCode.php @@ -26,11 +26,13 @@ class AuthCode extends Model /** * The database table used by the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'oauth_auth_codes'; /** * Indicates if the IDs are auto-incrementing. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutIncrementing] would override a subclass's $incrementing = true) public bool $incrementing = false; /** @@ -38,6 +40,7 @@ class AuthCode extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** @@ -55,11 +58,13 @@ class AuthCode extends Model /** * Indicates if the model should be timestamped. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutTimestamps] would override a subclass's $timestamps = true) public bool $timestamps = false; /** * The "type" of the primary key ID. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Table] would override a subclass's $keyType = 'int') protected string $keyType = 'string'; // The deprecated client() relation is omitted; client_id identifies the issuing client. diff --git a/src/passport/src/Client.php b/src/passport/src/Client.php index 4c23ed6b18..9cbcf8a56a 100644 --- a/src/passport/src/Client.php +++ b/src/passport/src/Client.php @@ -49,6 +49,7 @@ class Client extends Model /** * The database table used by the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'oauth_clients'; /** @@ -56,6 +57,7 @@ class Client extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** @@ -63,6 +65,7 @@ class Client extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Hidden] would merge with a subclass's $hidden instead of letting it replace this list) protected array $hidden = [ 'secret', ]; @@ -152,6 +155,8 @@ public function tokens(): TokenRelation /** * Interact with the client's secret. + * + * @return Attribute */ protected function secret(): Attribute { @@ -167,6 +172,8 @@ protected function secret(): Attribute /** * Interact with the client's plain secret. + * + * @return Attribute */ protected function plainSecret(): Attribute { diff --git a/src/passport/src/Consent.php b/src/passport/src/Consent.php index 821584d9bc..0275df3a62 100644 --- a/src/passport/src/Consent.php +++ b/src/passport/src/Consent.php @@ -34,6 +34,7 @@ class Consent extends Model /** * The database table used by the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'oauth_consents'; /** @@ -41,6 +42,7 @@ class Consent extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** diff --git a/src/passport/src/DeviceCode.php b/src/passport/src/DeviceCode.php index a3e8427c7a..3591ac31e5 100644 --- a/src/passport/src/DeviceCode.php +++ b/src/passport/src/DeviceCode.php @@ -26,21 +26,25 @@ class DeviceCode extends Model /** * The database table used by the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'oauth_device_codes'; /** * The "type" of the primary key ID. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Table] would override a subclass's $keyType = 'int') protected string $keyType = 'string'; /** * Indicates if the IDs are auto-incrementing. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutIncrementing] would override a subclass's $incrementing = true) public bool $incrementing = false; /** * Indicates if the model should be timestamped. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutTimestamps] would override a subclass's $timestamps = true) public bool $timestamps = false; /** @@ -48,6 +52,7 @@ class DeviceCode extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** diff --git a/src/passport/src/Passport.php b/src/passport/src/Passport.php index 7a15681836..6cea839ce2 100644 --- a/src/passport/src/Passport.php +++ b/src/passport/src/Passport.php @@ -19,6 +19,7 @@ use Hypervel\Passport\Contracts\DeviceUserCodeViewResponse; use Hypervel\Passport\Contracts\OAuthenticatable; use Hypervel\Passport\Contracts\ServerSettingsResolver; +use Hypervel\Passport\Guards\TokenGuard; use Hypervel\Passport\Http\Responses\SimpleViewResponse; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; @@ -667,7 +668,10 @@ public static function actingAsClient(Client $client, array $scopes = [], ?strin CoroutineContext::set(ServerFactory::RESOURCE_SERVER_CONTEXT_KEY, $mock); - app('auth')->guard($guard)->setClient($client); + /** @var TokenGuard $passportGuard */ + $passportGuard = app('auth')->guard($guard); + + $passportGuard->setClient($client); app('auth')->shouldUse($guard); diff --git a/src/passport/src/PushedAuthorizationRequest.php b/src/passport/src/PushedAuthorizationRequest.php index 24fd19b97a..6909406611 100644 --- a/src/passport/src/PushedAuthorizationRequest.php +++ b/src/passport/src/PushedAuthorizationRequest.php @@ -20,11 +20,13 @@ class PushedAuthorizationRequest extends Model /** * The database table used by the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'oauth_pushed_authorization_requests'; /** * Indicates if the IDs are auto-incrementing. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutIncrementing] would override a subclass's $incrementing = true) public bool $incrementing = false; /** @@ -32,6 +34,7 @@ class PushedAuthorizationRequest extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** @@ -48,11 +51,13 @@ class PushedAuthorizationRequest extends Model /** * Indicates if the model should be timestamped. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutTimestamps] would override a subclass's $timestamps = true) public bool $timestamps = false; /** * The "type" of the primary key ID. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Table] would override a subclass's $keyType = 'int') protected string $keyType = 'string'; /** diff --git a/src/passport/src/RefreshToken.php b/src/passport/src/RefreshToken.php index 62c98784e6..f68c04f87d 100644 --- a/src/passport/src/RefreshToken.php +++ b/src/passport/src/RefreshToken.php @@ -24,16 +24,19 @@ class RefreshToken extends Model /** * The database table used by the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'oauth_refresh_tokens'; /** * The "type" of the primary key ID. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Table] would override a subclass's $keyType = 'int') protected string $keyType = 'string'; /** * Indicates if the IDs are auto-incrementing. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutIncrementing] would override a subclass's $incrementing = true) public bool $incrementing = false; /** @@ -41,6 +44,7 @@ class RefreshToken extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** @@ -57,6 +61,7 @@ class RefreshToken extends Model /** * Indicates if the model should be timestamped. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutTimestamps] would override a subclass's $timestamps = true) public bool $timestamps = false; /** diff --git a/src/passport/src/RefreshTokenFamily.php b/src/passport/src/RefreshTokenFamily.php index c591101886..cc20575985 100644 --- a/src/passport/src/RefreshTokenFamily.php +++ b/src/passport/src/RefreshTokenFamily.php @@ -23,6 +23,7 @@ class RefreshTokenFamily extends Model /** * The database table used by the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'oauth_refresh_token_families'; /** @@ -30,6 +31,7 @@ class RefreshTokenFamily extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** diff --git a/src/passport/src/ServerFactory.php b/src/passport/src/ServerFactory.php index 0f79601379..8af61791f8 100644 --- a/src/passport/src/ServerFactory.php +++ b/src/passport/src/ServerFactory.php @@ -283,7 +283,7 @@ public function resourceServer(): ResourceServer if ($entry->resourceServer === null) { $repository = $this->app->make(Bridge\AccessTokenRepository::class); - $publicKey = $entry->verificationKeys[array_key_first($entry->verificationKeys)] ?? null; + $publicKey = array_first($entry->verificationKeys); if ($publicKey === null) { throw new LogicException('No Passport verification keys are available. Generate or import signing keys first.'); diff --git a/src/passport/src/SigningKey.php b/src/passport/src/SigningKey.php index 566ad409f4..ffcad34523 100644 --- a/src/passport/src/SigningKey.php +++ b/src/passport/src/SigningKey.php @@ -31,6 +31,7 @@ class SigningKey extends Model /** * The database table used by the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'oauth_signing_keys'; /** @@ -38,6 +39,7 @@ class SigningKey extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** @@ -45,6 +47,7 @@ class SigningKey extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Hidden] would merge with a subclass's $hidden instead of letting it replace this list) protected array $hidden = ['private_key']; /** diff --git a/src/passport/src/Token.php b/src/passport/src/Token.php index be874903fa..d67bc3629b 100644 --- a/src/passport/src/Token.php +++ b/src/passport/src/Token.php @@ -33,16 +33,19 @@ class Token extends Model /** * The database table used by the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'oauth_access_tokens'; /** * The "type" of the primary key ID. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Table] would override a subclass's $keyType = 'int') protected string $keyType = 'string'; /** * Indicates if the IDs are auto-incrementing. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutIncrementing] would override a subclass's $incrementing = true) public bool $incrementing = false; /** @@ -50,6 +53,7 @@ class Token extends Model * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Unguarded] would replace a $guarded = ['*'] a subclass constructor sets before parent::__construct()) protected array $guarded = []; /** diff --git a/src/pennant/src/Drivers/Decorator.php b/src/pennant/src/Drivers/Decorator.php index 66e63356d7..b1ee267b01 100644 --- a/src/pennant/src/Drivers/Decorator.php +++ b/src/pennant/src/Drivers/Decorator.php @@ -197,7 +197,7 @@ public function define(UnitEnum|string $feature, mixed $resolver = null): void $this->driver->define($feature, function (mixed $scope) use ($feature, $resolver): mixed { if ($resolver instanceof LazilyResolvedFeature) { $resolver = with($this->container->make($resolver->feature), fn ($instance) => method_exists($instance, 'resolve') - ? $instance->resolve(...) // @phpstan-ignore callable.nonNativeMethod + ? $instance->resolve(...) : $instance(...)); } @@ -1049,6 +1049,8 @@ public function getDriver(): Driver * Set the container instance used by the decorator. * * Tests only. The container persists on the store for the worker lifetime. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/pennant/src/FeatureManager.php b/src/pennant/src/FeatureManager.php index 0d2881137b..d7cde1fbaa 100644 --- a/src/pennant/src/FeatureManager.php +++ b/src/pennant/src/FeatureManager.php @@ -302,6 +302,8 @@ public function serializeScope(mixed $scope): string * * Boot-only. The setting persists on the manager for the worker lifetime and * changes how every subsequent request serializes model scopes. + * + * @return $this */ public function useMorphMap(bool $value = true): static { @@ -469,6 +471,7 @@ public function setDefaultDriver(string $name): void * the current coroutine's values for a forgotten store are flushed. * * @param null|array|string $name + * @return $this */ public function forgetDriver(array|string|null $name = null): static { @@ -490,6 +493,8 @@ public function forgetDriver(array|string|null $name = null): static * * Boot or tests only. Stores are shared by every request in the worker, and * the current coroutine's values for the forgotten stores are flushed. + * + * @return $this */ public function forgetDrivers(): static { @@ -506,6 +511,8 @@ public function forgetDrivers(): static * Register a custom driver creator Closure. * * Boot-only. The creator persists on the manager for the worker lifetime. + * + * @return $this */ public function extend(string $driver, Closure $callback): static { @@ -519,6 +526,8 @@ public function extend(string $driver, Closure $callback): static * * Tests only. The container persists on the manager and its stores for the * worker lifetime. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/pennant/src/PendingScopedFeatureInteraction.php b/src/pennant/src/PendingScopedFeatureInteraction.php index 819192e42b..1745feb09a 100644 --- a/src/pennant/src/PendingScopedFeatureInteraction.php +++ b/src/pennant/src/PendingScopedFeatureInteraction.php @@ -35,6 +35,8 @@ public function __construct(Decorator $driver) /** * Add scope to the feature interaction. + * + * @return $this */ public function for(mixed $scope): static { diff --git a/src/permission/composer.json b/src/permission/composer.json index 36de3682ea..21691389e7 100644 --- a/src/permission/composer.json +++ b/src/permission/composer.json @@ -52,8 +52,8 @@ "hypervel/view": "^0.4", "nesbot/carbon": "^3.13.1", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "config": { "sort-packages": true diff --git a/src/permission/src/Commands/ShowCommand.php b/src/permission/src/Commands/ShowCommand.php index 9f3e680274..5213d1f3bd 100644 --- a/src/permission/src/Commands/ShowCommand.php +++ b/src/permission/src/Commands/ShowCommand.php @@ -88,7 +88,7 @@ public function handle(PermissionRegistrar $permissionRegistrar): int array_pop($name); return implode('_', $name); - })->toArray(); + })->all(); array_unshift($roleHeaders, new TableCell('')); $teamHeaders = $teams ? $teams->toArray() : []; diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php index ef041343a3..36070ca87c 100644 --- a/src/permission/src/Traits/HasPermissions.php +++ b/src/permission/src/Traits/HasPermissions.php @@ -510,6 +510,7 @@ protected function allowedDirectPermissions(): Collection * @param Builder $query * @return Builder */ + // @phpstan-ignore hypervel.legacyModelScope (reusable trait: a using model's own method of the scope's name would silently replace an attributed scope) public function scopePermission( Builder $query, array|Collection|int|Permission|string|UnitEnum $permissions, @@ -611,6 +612,7 @@ protected function whereRolePermissionEffect(Builder $query, int|string $permiss * @param Builder $query * @return Builder */ + // @phpstan-ignore hypervel.legacyModelScope (reusable trait: a using model's own method of the scope's name would silently replace an attributed scope) public function scopeWithoutPermission(Builder $query, array|Collection|int|Permission|string|UnitEnum $permissions): Builder { return $this->scopePermission($query, $permissions, true); diff --git a/src/permission/src/Traits/HasRoles.php b/src/permission/src/Traits/HasRoles.php index 9920ac3d6e..45e76f787c 100644 --- a/src/permission/src/Traits/HasRoles.php +++ b/src/permission/src/Traits/HasRoles.php @@ -200,6 +200,7 @@ protected function getCachedRoles(): Collection * @param Builder $query * @return Builder */ + // @phpstan-ignore hypervel.legacyModelScope (reusable trait: a using model's own method of the scope's name would silently replace an attributed scope) public function scopeRole( Builder $query, array|Collection|int|Role|string|UnitEnum $roles, @@ -251,6 +252,7 @@ public function scopeRole( * @param Builder $query * @return Builder */ + // @phpstan-ignore hypervel.legacyModelScope (reusable trait: a using model's own method of the scope's name would silently replace an attributed scope) public function scopeWithoutRole(Builder $query, array|Collection|int|Role|string|UnitEnum $roles, ?string $guard = null): Builder { return $this->scopeRole($query, $roles, $guard, true); @@ -298,6 +300,7 @@ public function teams(): BelongsToMany * @param Builder $query * @return Builder */ + // @phpstan-ignore hypervel.legacyModelScope (reusable trait: a using model's own method of the scope's name would silently replace an attributed scope) public function scopeTeam(Builder $query, array|Collection|int|Model|string $teams, bool $without = false): Builder { $teamModel = Config::teamModel(); @@ -338,6 +341,7 @@ function ($subQuery) use ($pivotTable, $morphKey, $query, $teamsKey, $teamIds, $ * @param Builder $query * @return Builder */ + // @phpstan-ignore hypervel.legacyModelScope (reusable trait: a using model's own method of the scope's name would silently replace an attributed scope) public function scopeWithoutTeam(Builder $query, array|Collection|int|Model|string $teams): Builder { return $this->scopeTeam($query, $teams, true); diff --git a/src/phpstan/LICENSE.md b/src/phpstan/LICENSE.md new file mode 100644 index 0000000000..818ce5f419 --- /dev/null +++ b/src/phpstan/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Can Vural , Nuno Maduro , + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/phpstan/README.md b/src/phpstan/README.md new file mode 100644 index 0000000000..8d7fddf108 --- /dev/null +++ b/src/phpstan/README.md @@ -0,0 +1,32 @@ +PHPStan for Hypervel +=== + +Documentation: https://hypervel.org/docs/static-analysis + +## Differences From Larastan + +- Package-owned diagnostic identifiers use the `hypervel.*` prefix instead of `larastan.*` (and `rules.modelAppends`). PHPStan's own identifiers are unchanged. +- Several checks are enabled by default, and Hypervel adds coroutine, worker-lifetime and authoring-policy checks. See the [configuration defaults](https://hypervel.org/docs/static-analysis#configuration). +- There is no Octane compatibility rule or `checkOctaneCompatibility` setting; Hypervel's worker-lifetime checks replace it. There is no deferred service provider rule, because Hypervel has no deferred providers. +- Container ArrayAccess return types are not provided, because the Hypervel container does not support ArrayAccess. +- A value typed as an interface does not gain methods or macros from its implementations. Upstream resolves an undefined method or a macro on a contract against its container binding, and on the auth user contracts against the configured auth models. Explicit resolution, such as `app(Repository::class)` or the factory returned by `response()`, is typed from the booted application's binding instead. +- Without a guard name, `Auth::guard()` and `auth()->guard()` may return any configured guard, because `shouldUse()` changes the default guard per request. A guard whose type cannot be read from its driver keeps the declared `Guard` type rather than `StatefulGuard`. +- The request-scope auth rules offer no automatic fix, because the request's user resolver can return a different user than the `Auth` facade or `auth()` helper. +- Collections keep Laravel's covariant value type, so a `Collection` is accepted where a `Collection` is expected. Upstream's stubs make it invariant. With bleeding edge, PHPStan does not widen a covariant value type it has already inferred, so a call such as `collect([1, 2, 3])->push(4)` is reported. See [Collection Types](https://hypervel.org/docs/static-analysis#collection-types). +- `now()`, `today()` and the `Date` facade return `CarbonInterface`, as declared, rather than the date class configured when analysis runs. Code that needs a specific class should convert the value, for example with `toImmutable()`. +- The `generalizeEnvReturnType` and `redisDriver` settings are omitted. An `env()` default does not decide the type of the value it reads, and Redis types always describe Hypervel's phpredis connection. +- Request input getters return the types the input can hold. A keyed `query()`, `post()` or `cookie()` value can be a string, number, boolean, array or `Stringable`, a keyed `server()` or `route()` value is `mixed`, and `header()` can return null even with a default. Whole query, post, cookie and file arrays have `array-key` keys, because a query string or JSON list body can use integer keys. Upstream's narrower types do not cover these values. `FormRequest::validated()` and `safe($keys)` return `array` for the same reason. +- `Storage::disk()` and `FilesystemManager::disk()` are typed per disk from the booted configuration, such as `ClientPooledFilesystem` for a pooled S3 disk, instead of always `FilesystemAdapter`. Calls the `Storage` facade forwards to the default disk use that disk's methods. `Storage::cloud()` is not supported. +- Connection-bound Redis methods such as `client()` and `auth()` are reported when called through `Redis::connection()` or the manager, because the pooled proxy rejects them. Call them on a held connection from `Redis::withConnection()`. +- A builder method called through a relation returns the relation only when it is declared to return `static` or `$this`, as a relation returns itself when the builder returns itself. Upstream does so for any builder result. `static` is treated as the fluent convention, not proof of identity, so a custom `static` method that returns a clone is still typed as the relation; declare `$this` where identity matters. Builder's own `clone()`, `cloneWithout()`, `cloneWithoutBindings()` and `applyScopes()` return the builder. +- A `@method scopeActive()` tag does not add an `active()` scope, because Eloquent only calls scope methods that exist. Upstream adds one. +- The model visibility rule makes only a private scope or accessor protected automatically. A public one may be called directly from other files, so it is reported without a fix. +- The unnecessary collection call rule reports a call only when the query returns the same result, so `last()`, `pop()`, `skip()`, `slice()`, `where()`, `contains()`, `find()`, the aggregates and similar calls are not reported. See [Query Results](https://hypervel.org/docs/static-analysis#query-results). +- A dynamic where, such as `whereEmail()`, that names an unknown column is still reported as an undefined method, with the column in a tip. Unlike upstream, the call keeps the builder or relation type rather than becoming an error type. +- Calls an Eloquent builder forwards to its query follow `Builder::__call()`. A query macro returns the builder, or the relation it was called through, instead of the macro's result. A passthru method such as `dumpRawSql()` returns the raw query, whose rows are not models. `macro()` and a static `mixin()` return null, and a forwarded query method cannot be called statically on a builder class. Upstream keeps the query macro's and passthru results, returns the builder from both registration methods, and allows those static calls. A local macro keeps its own `$this`, while `Builder::macro()` binds a global macro to the builder. Upstream binds both as the query's `macro()` declares. +- `relation-of` gives a strict union when several relations may apply, so an eager-loading callback must accept each of them. Upstream gives a benevolent union. +- A relation path is unknown when any model in a union lacks the relation, because a subclass or `resolveRelationUsing()` can supply it. `builder-of` and `collection-of` of an unknown model give `Builder<*>` and the base model collection. Upstream drops that model, or gives `never`. +- A custom builder's or relation's override of a method such as `get()` keeps its declared collection type. Upstream replaces it with the model's collection class. +- A job dispatched in a transaction is reported only when its queue connection is known not to wait for the commit, from the connection's `after_commit` and outbox settings and, for the database driver, the transaction's connection. A dispatch that calls `beforeCommit()`, or a job whose `$afterCommit` is `false`, is not reported. Upstream assumes Laravel's default, where connections dispatch immediately, and reports both. See [Dispatching Jobs in Transactions](https://hypervel.org/docs/static-analysis#dispatching-jobs-in-transactions). + +Ported from: https://github.com/larastan/larastan diff --git a/src/phpstan/bootstrap.php b/src/phpstan/bootstrap.php new file mode 100644 index 0000000000..722ec51430 --- /dev/null +++ b/src/phpstan/bootstrap.php @@ -0,0 +1,59 @@ +environmentFilePath(); + + $app->make(Kernel::class)->bootstrap(); + + RuntimeInputs::captureBoot($app, $environmentFile, $packageApplication); +} catch (Throwable $throwable) { + $argv = $_SERVER['argv'] ?? []; + $decorated = null; + + if (in_array('--no-ansi', $argv, true)) { + $decorated = false; + } elseif (in_array('--ansi', $argv, true)) { + $decorated = true; + } + + (new BootstrapErrorHandler(decorated: $decorated))->handle($throwable); + exit(1); +} diff --git a/src/phpstan/composer.json b/src/phpstan/composer.json new file mode 100644 index 0000000000..b3b55b9f44 --- /dev/null +++ b/src/phpstan/composer.json @@ -0,0 +1,70 @@ +{ + "name": "hypervel/phpstan", + "type": "phpstan-extension", + "description": "Framework-aware PHPStan type inference and rules for Hypervel applications and packages.", + "license": "MIT", + "keywords": [ + "php", + "swoole", + "phpstan", + "static analysis", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\PHPStan\\": "src/" + } + }, + "require": { + "php": "^8.4", + "composer-runtime-api": "^2.2", + "hypervel/collections": "^0.4", + "hypervel/console": "^0.4", + "hypervel/container": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/database": "^0.4", + "hypervel/filesystem": "^0.4", + "hypervel/foundation": "^0.4", + "hypervel/http": "^0.4", + "hypervel/routing": "^0.4", + "hypervel/schema-parser": "^0.4", + "hypervel/support": "^0.4", + "hypervel/translation": "^0.4", + "hypervel/view": "^0.4", + "phpstan/phpstan": "^2.3.5", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "symfony/console": "^8.1.10", + "symfony/finder": "^8.1", + "vlucas/phpdotenv": "^5.6.1" + }, + "suggest": { + "hypervel/testbench": "Required to analyze packages with a Testbench-booted application (^0.4)." + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + } +} diff --git a/src/phpstan/extension.neon b/src/phpstan/extension.neon new file mode 100644 index 0000000000..f4678c3ba0 --- /dev/null +++ b/src/phpstan/extension.neon @@ -0,0 +1,189 @@ +attributeServicesDirectories: + - src + +parameters: + universalObjectCratesClasses: + - Hypervel\Http\Request + - Hypervel\Support\Optional + # abort() and dd() declare native never returns, so they need no earlyTerminatingFunctionCalls entries. + mixinExcludeClasses: + - Eloquent + bootstrapFiles: + - bootstrap.php + reportIgnoresWithoutComments: true + # checkOctaneCompatibility is omitted: the worker-lifetime checks below replace the Octane rule. + noEnvCallsOutsideOfConfig: true + noModelMake: true + noImplicitQueryBuilderCall: false + noUnnecessaryCollectionCall: true + noUnnecessaryCollectionCallOnly: [] + noUnnecessaryCollectionCallExcept: [] + noUnnecessaryEnumerableToArrayCalls: true + squashedMigrationsPath: [] + databaseMigrationsPath: [] + disableMigrationScan: false + disableSchemaScan: false + configDirectories: [] + viewDirectories: [] + translationDirectories: [] + checkModelProperties: true + checkUnusedViews: false + checkMissingTranslations: true + checkModelAppends: true + checkModelMethodVisibility: true + # generalizeEnvReturnType is omitted: env() declares its return type, which a default does not decide. + checkConfigTypes: true + checkAuthCallsWhenInRequestScope: false + parseModelCastsMethod: true + enableMigrationCache: true + checkUniqueJobUniqueFor: false + checkUniqueJobUniqueId: true + noBatchedUniqueJob: true + checkJobSerializesModels: true + checkBatchedJobIsBatchable: true + checkBatchableJobChecksCancellation: false + checkDispatchInTransactionAfterCommit: true + checkCancellationHandling: true + checkCancellationPrecedence: true + checkWorkerSharedState: true + checkStaticStateMutation: true + checkUnboundedWorkerCaches: true + noRuntimeConfigMutation: true + noCoroutinesInTransactions: true + noRawSleepCalls: true + noLegacyModelScopes: true + noLegacyModelProperties: true + noLegacyQueueProperties: true + noPipeSeparatedValidationRules: true + checkRedisConnectionBoundMethods: true + # redisDriver is omitted: Redis types always describe Hypervel's phpredis connection. + # Builder macros added by model traits' global scopes: trait => interface declaring the macros. + eloquentBuilderMacros: + Hypervel\Database\Eloquent\SoftDeletes: Hypervel\PHPStan\Methods\Macros\SoftDeletingMacros + Hypervel\Scout\Searchable: Hypervel\PHPStan\Methods\Macros\SearchableMacros + +parametersSchema: + noEnvCallsOutsideOfConfig: bool() + noModelMake: bool() + noImplicitQueryBuilderCall: bool() + noUnnecessaryCollectionCall: bool() + noUnnecessaryCollectionCallOnly: listOf(string()) + noUnnecessaryCollectionCallExcept: listOf(string()) + noUnnecessaryEnumerableToArrayCalls: bool() + databaseMigrationsPath: listOf(string()) + disableMigrationScan: bool() + configDirectories: listOf(string()) + viewDirectories: listOf(string()) + translationDirectories: listOf(string()) + squashedMigrationsPath: listOf(string()) + disableSchemaScan: bool() + checkModelProperties: bool() + checkUnusedViews: bool() + checkMissingTranslations: bool() + checkModelAppends: bool() + checkModelMethodVisibility: bool() + checkConfigTypes: bool() + checkAuthCallsWhenInRequestScope: bool() + parseModelCastsMethod: bool() + enableMigrationCache: bool() + checkUniqueJobUniqueFor: bool() + checkUniqueJobUniqueId: bool() + noBatchedUniqueJob: bool() + checkJobSerializesModels: bool() + checkBatchedJobIsBatchable: bool() + checkBatchableJobChecksCancellation: bool() + checkDispatchInTransactionAfterCommit: bool() + checkCancellationHandling: bool() + checkCancellationPrecedence: bool() + checkWorkerSharedState: bool() + checkStaticStateMutation: bool() + checkUnboundedWorkerCaches: bool() + noRuntimeConfigMutation: bool() + noCoroutinesInTransactions: bool() + noRawSleepCalls: bool() + noLegacyModelScopes: bool() + noLegacyModelProperties: bool() + noLegacyQueueProperties: bool() + noPipeSeparatedValidationRules: bool() + checkRedisConnectionBoundMethods: bool() + eloquentBuilderMacros: arrayOf(string(), string()) + +services: + # Upstream's ContainerArrayAccessDynamicMethodReturnTypeExtension entries are omitted: the container has no ArrayAccess. + - + class: Hypervel\PHPStan\Types\AbortIfFunctionTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.functionTypeSpecifyingExtension + arguments: + methodName: 'abort' + negate: false + + - + class: Hypervel\PHPStan\Types\AbortIfFunctionTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.functionTypeSpecifyingExtension + arguments: + methodName: 'abort' + negate: true + + - + class: Hypervel\PHPStan\Types\AbortIfFunctionTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.functionTypeSpecifyingExtension + arguments: + methodName: throw + negate: false + + - + class: Hypervel\PHPStan\Types\AbortIfFunctionTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.functionTypeSpecifyingExtension + arguments: + methodName: throw + negate: true + + # The contract also covers the concrete application, so upstream's second entry is omitted. + - + class: Hypervel\PHPStan\ReturnTypes\AppEnvironmentReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + arguments: + class: "Hypervel\\Contracts\\Foundation\\Application" + + - + class: Hypervel\PHPStan\ReturnTypes\FormRequestSafeDynamicMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + arguments: + class: "Hypervel\\Foundation\\Http\\FormRequest" + + - + class: Hypervel\PHPStan\ReturnTypes\FormRequestSafeDynamicMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + arguments: + class: "Hypervel\\Contracts\\Validation\\Validator" + + - + class: Hypervel\PHPStan\Rules\CheckDispatchArgumentTypesCompatibleWithClassConstructorRule + arguments: + dispatchableClass: Hypervel\Foundation\Bus\Dispatchable + tags: + - phpstan.rules.rule + - + class: Hypervel\PHPStan\Rules\CheckDispatchArgumentTypesCompatibleWithClassConstructorRule + arguments: + dispatchableClass: Hypervel\Foundation\Events\Dispatchable + tags: + - phpstan.rules.rule + + - + class: Hypervel\Filesystem\Filesystem + autowired: self + + migrationsParser: + class: PHPStan\Parser\CachedParser + arguments: + originalParser: @currentPhpVersionSimpleDirectParser + cachedNodesByStringCountMax: %cache.nodesByStringCountMax% + autowired: false diff --git a/src/phpstan/src/ApplicationResolver.php b/src/phpstan/src/ApplicationResolver.php new file mode 100644 index 0000000000..2b842f660f --- /dev/null +++ b/src/phpstan/src/ApplicationResolver.php @@ -0,0 +1,51 @@ + $config->getExtraAttributes(), + 'discovers_root_package' => true, + ]); + } +} diff --git a/src/phpstan/src/AttributeServicesResultCacheMetaExtension.php b/src/phpstan/src/AttributeServicesResultCacheMetaExtension.php new file mode 100644 index 0000000000..9fdc327e44 --- /dev/null +++ b/src/phpstan/src/AttributeServicesResultCacheMetaExtension.php @@ -0,0 +1,54 @@ + $classes + */ + public function __construct( + #[AutowiredParameter(ref: '%attributeServicesClasses%')] + private array $classes, + ) { + } + + /** + * Get the metadata key. + */ + public function getKey(): string + { + return 'hypervel.attributeServicesClasses'; + } + + /** + * Get the hash of the attribute-registered service classes. + */ + public function getHash(): string + { + $classes = $this->classes; + sort($classes); + + return hash('xxh128', implode("\n", $classes)); + } +} diff --git a/src/phpstan/src/ClosureTypes/RelationConditionableCallbackExtension.php b/src/phpstan/src/ClosureTypes/RelationConditionableCallbackExtension.php new file mode 100644 index 0000000000..43927e0d9a --- /dev/null +++ b/src/phpstan/src/ClosureTypes/RelationConditionableCallbackExtension.php @@ -0,0 +1,74 @@ +getName()), ['when', 'unless'], true) + && in_array($parameter->getName(), ['callback', 'default'], true) + && $methodReflection->getDeclaringClass()->is(Relation::class) + && $methodReflection->getPrototype() instanceof ForwardedMethodReflection; + } + + /** + * Return the callback type with the builder it receives as a "this" type. + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, ParameterReflection $parameter, Scope $scope): Type + { + $type = $parameter->getType(); + $callables = TypeCombinator::removeNull($type)->getCallableParametersAcceptors($scope); + $parameters = count($callables) === 1 ? $callables[0]->getParameters() : []; + $builderClasses = isset($parameters[0]) ? $parameters[0]->getType()->getObjectClassReflections() : []; + + if (count($builderClasses) !== 1) { + return $type; + } + + // Only the first parameter is the relation's builder; the value passed second may be any other builder. + $parameters[0] = new CallableParameterReflection($parameters[0]->getName(), new ThisType($builderClasses[0])); + + return TypeCombinator::addNull(new CallableType( + $parameters, + $callables[0]->getReturnType(), + $callables[0]->isVariadic(), + $callables[0]->getTemplateTypeMap(), + $callables[0]->getResolvedTemplateTypeMap(), + )); + } +} diff --git a/src/phpstan/src/ClosureTypes/RelationshipQueryCallbackExtension.php b/src/phpstan/src/ClosureTypes/RelationshipQueryCallbackExtension.php new file mode 100644 index 0000000000..e7fe21f6dd --- /dev/null +++ b/src/phpstan/src/ClosureTypes/RelationshipQueryCallbackExtension.php @@ -0,0 +1,155 @@ +queriedModel($methodReflection->getDeclaringClass()) === null) { + return false; + } + + return match ($parameter->getName()) { + 'callback' => in_array($methodReflection->getName(), ['hasMorph', 'doesntHaveMorph', 'whereHasMorph', 'orWhereHasMorph', 'whereDoesntHaveMorph', 'orWhereDoesntHaveMorph'], true), + 'column' => in_array($methodReflection->getName(), ['whereMorphRelation', 'orWhereMorphRelation', 'whereMorphDoesntHaveRelation', 'orWhereMorphDoesntHaveRelation'], true), + default => false, + }; + } + + /** + * Determine whether the parameter is a polymorphic relationship query callback. + */ + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $this->isMethodSupported($methodReflection, $parameter); + } + + /** + * Return the callback type for an instance call. + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + return $this->getCallbackType($methodReflection, $methodCall, $parameter, $scope); + } + + /** + * Return the callback type for a static call. + */ + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + return $this->getCallbackType($methodReflection, $methodCall, $parameter, $scope); + } + + /** + * Return the model a forwarded or native builder method queries. + * + * Models and relations declare the builder methods they forward, so their calls reach this + * extension with the model or relation as the declaring class. + */ + private function queriedModel(ClassReflection $declaringClass): ?Type + { + if ($declaringClass->is(Model::class)) { + return new ObjectType($declaringClass->getName()); + } + + return $declaringClass->getAncestorWithClassName(Builder::class)?->getActiveTemplateTypeMap()->getType('TModel') + ?? $declaringClass->getAncestorWithClassName(Relation::class)?->getActiveTemplateTypeMap()->getType('TRelatedModel'); + } + + /** + * Return the callback type with the builders of the queried morph types. + */ + private function getCallbackType(MethodReflection $methodReflection, MethodCall|StaticCall $call, ParameterReflection $parameter, Scope $scope): ?Type + { + $arguments = []; + + foreach ($call->getArgs() as $position => $argument) { + // An unpacked argument hides which values the relation and types are. + if ($argument->unpack) { + return null; + } + + $arguments[$argument->name?->toString() ?? $position] = $argument->value; + } + + $relation = $arguments['relation'] ?? $arguments[0] ?? null; + $types = $arguments['types'] ?? $arguments[1] ?? null; + $model = $this->queriedModel($methodReflection->getDeclaringClass()); + + if ($relation === null || $types === null || $model === null) { + return null; + } + + // The callbacks run on each queried model's newQueryWithoutRelationships(). + $fallback = []; + + foreach (TypeUtils::flattenTypes($scope->getType($relation)) as $relationType) { + $fallback[] = $relationType->isString()->yes() + ? (new BuilderOfType($model, $this->builderHelper, $relationType, modelQuery: true))->resolve() + : (new BuilderOfType($relationType->getTemplateType(Relation::class, 'TRelatedModel'), $this->builderHelper, modelQuery: true))->resolve(); + } + + $fallback = TypeCombinator::union(...$fallback); + $builders = []; + + foreach (TypeUtils::flattenTypes($scope->getType($types)) as $type) { + if ($type->isArray()->yes()) { + $type = $type->getIterableValueType(); + } + + foreach (TypeUtils::flattenTypes($type) as $target) { + $objectType = $target->getClassStringObjectType(); + $builders[] = $target->isClassString()->yes() && (new ObjectType(Model::class))->isSuperTypeOf($objectType)->yes() + ? (new BuilderOfType($objectType, $this->builderHelper, modelQuery: true))->resolve() + : $fallback; + } + } + + $queryType = TypeCombinator::union(...$builders); + + return TypeTraverser::map($parameter->getType(), static function (Type $type, callable $traverse) use ($queryType): Type { + if ($type instanceof ClosureType) { + return $type->traverse(static fn (Type $parameterType): Type => TypeCombinator::union(new ObjectType(Builder::class), new ObjectType(Relation::class))->isSuperTypeOf($parameterType)->yes() ? $queryType : $parameterType); + } + + return $traverse($type); + }); + } +} diff --git a/src/phpstan/src/Collectors/UnusableSchemaDumpCollector.php b/src/phpstan/src/Collectors/UnusableSchemaDumpCollector.php new file mode 100644 index 0000000000..ecf9483f9a --- /dev/null +++ b/src/phpstan/src/Collectors/UnusableSchemaDumpCollector.php @@ -0,0 +1,65 @@ +> + */ +#[AutowiredService] +class UnusableSchemaDumpCollector implements Collector +{ + /** + * Create a new collector instance. + */ + public function __construct(private ModelPropertyHelper $modelPropertyHelper) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return PropertyFetch::class; + } + + /** + * Collect the unusable schema dumps of the models whose attributes the fetch looks up. + * + * @param PropertyFetch $node + * + * @return null|list + */ + public function processNode(Node $node, Scope $scope): ?array + { + if (! $node->name instanceof Identifier) { + return null; + } + + $dumps = []; + + foreach ($scope->getType($node->var)->getObjectClassReflections() as $classReflection) { + // A declared property is read directly, without consulting the schema. + if (! $classReflection->hasNativeProperty($node->name->name)) { + $dumps = [...$dumps, ...$this->modelPropertyHelper->getUnusableDumps($classReflection)]; + } + } + + return $dumps === [] ? null : $dumps; + } +} diff --git a/src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php b/src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php new file mode 100644 index 0000000000..0a95ba75d2 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedEmailAlternativeSyntaxViewCollector.php @@ -0,0 +1,77 @@ +> */ +#[AutowiredService] +class UsedEmailAlternativeSyntaxViewCollector implements Collector +{ + use CollectsViewNames; + + private const array VIEW_PARAM_NAMES = ['view', 'html', 'text', 'markdown']; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return New_::class; + } + + /** + * Collect the views passed to a mail content constructor. + * + * @param New_ $node + */ + public function processNode(Node $node, Scope $scope): ?array + { + $class = $node->class; + + if (! $class instanceof Name) { + return null; + } + + if (count($node->getArgs()) < 1) { + return null; + } + + $class = $scope->resolveName($class); + + if ($class !== Content::class) { + return null; + } + + $views = []; + + foreach ($node->getArgs() as $index => $arg) { + if ($arg->name !== null) { + $paramName = $arg->name->name; + } else { + $paramName = self::VIEW_PARAM_NAMES[$index] ?? null; + } + + if ($paramName === null || ! in_array($paramName, self::VIEW_PARAM_NAMES, true)) { + continue; + } + + array_push($views, ...$this->getViewNames($arg->value, $scope) ?? []); + } + + return $views !== [] ? $views : null; + } +} diff --git a/src/phpstan/src/Collectors/UsedEmailSendViewCollector.php b/src/phpstan/src/Collectors/UsedEmailSendViewCollector.php new file mode 100644 index 0000000000..fe20d37878 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedEmailSendViewCollector.php @@ -0,0 +1,99 @@ +> */ +#[AutowiredService] +class UsedEmailSendViewCollector implements Collector +{ + use CollectsMailViews; + + /** + * The lowercase mailer methods that take a view, with the name of their view parameter. + * + * Upstream reads only send(). + * + * @var array + */ + private const array VIEW_PARAMETERS = [ + 'send' => 'view', + 'sendnow' => 'mailable', + 'plain' => 'view', + 'render' => 'view', + ]; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Collect the views passed to a mailer call. + * + * @param CallLike $node + * @return null|list + */ + public function processNode(Node $node, Scope $scope): ?array + { + if ( + ! $node instanceof StaticCall + && ! $node instanceof MethodCall + ) { + return null; + } + + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + $parameter = self::VIEW_PARAMETERS[$name->toLowerString()] ?? null; + $view = $parameter === null ? null : $node->getArg($parameter, 0)?->value; + + if ($view === null) { + return null; + } + + if ($node instanceof StaticCall) { + $class = $node->class; + + if (! $class instanceof Name) { + return null; + } + + $class = $scope->resolveName($class); + + if (! (new ObjectType(Mail::class))->isSuperTypeOf(new ObjectType($class))->yes()) { + return null; + } + } else { + $type = $scope->getType($node->var); + + if (! (new ObjectType(MailerContract::class))->isSuperTypeOf($type)->yes()) { + return null; + } + } + + return $this->getMailViews($view, $scope); + } +} diff --git a/src/phpstan/src/Collectors/UsedEmailViewCollector.php b/src/phpstan/src/Collectors/UsedEmailViewCollector.php new file mode 100644 index 0000000000..1392918420 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedEmailViewCollector.php @@ -0,0 +1,86 @@ +> */ +#[AutowiredService] +class UsedEmailViewCollector implements Collector +{ + use CollectsMailViews; + + private const array VIEW_METHOD_NAMES = ['view', 'html', 'text', 'markdown']; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Collect the view passed to a mailable, mail message or mail content method. + * + * @param MethodCall $node + * @return null|list + */ + public function processNode(Node $node, Scope $scope): ?array + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + $method = $name->toLowerString(); + + if (! in_array($method, self::VIEW_METHOD_NAMES, true)) { + return null; + } + + if (count($node->getArgs()) === 0) { + return null; + } + + $class = $node->var; + + $type = $scope->getType($class); + + $isContent = (new ObjectType(Content::class))->isSuperTypeOf($type)->yes(); + + if ($method === 'html' && ! $isContent) { + return null; + } + + if ( + ! (new ObjectType(Mailable::class))->isSuperTypeOf($type)->yes() + && ! (new ObjectType(MailMessage::class))->isSuperTypeOf($type)->yes() + && ! $isContent + ) { + return null; + } + + // Upstream reads the first argument by position, which misses reordered named arguments. The + // mailable and mail message text() methods name it textView; the others name it view. + $parameter = $method === 'text' && ! $isContent ? 'textView' : 'view'; + + return $this->getMailViews($node->getArg($parameter, 0)?->value, $scope); + } +} diff --git a/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php b/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php new file mode 100644 index 0000000000..ae9ffe9659 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedRouteFacadeViewCollector.php @@ -0,0 +1,65 @@ +> */ +#[AutowiredService] +class UsedRouteFacadeViewCollector implements Collector +{ + use CollectsViewNames; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Collect the view registered by a Route::view() call. + * + * @param StaticCall $node + * @return null|list + */ + public function processNode(Node $node, Scope $scope): ?array + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + if ($name->toLowerString() !== 'view') { + return null; + } + + $class = $node->class; + + if (! $class instanceof Name) { + return null; + } + + $class = $scope->resolveName($class); + + if (! (new ObjectType(Route::class))->isSuperTypeOf(new ObjectType($class))->yes()) { + return null; + } + + // Upstream reads the second argument by position, which misses reordered named arguments. + return $this->getViewNames($node->getArg('view', 1)?->value, $scope); + } +} diff --git a/src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php b/src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php new file mode 100644 index 0000000000..69f87fbe78 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedTranslationFacadeCollector.php @@ -0,0 +1,75 @@ + + */ +#[AutowiredService] +class UsedTranslationFacadeCollector implements Collector +{ + use CollectsTranslationLookups; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Collect the translation looked up through the Lang facade. + * + * @param StaticCall $node + * + * @return null|TranslationLookup + */ + public function processNode(Node $node, Scope $scope): ?array + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + $method = $name->toLowerString(); + + if (! in_array($method, ['get', 'choice'], true)) { + return null; + } + + $class = $node->class; + + if (! $class instanceof Name) { + return null; + } + + $class = $scope->resolveName($class); + + if (! (new ObjectType(Lang::class))->isSuperTypeOf(new ObjectType($class))->yes()) { + return null; + } + + return $method === 'get' + ? $this->translationLookup($node, choice: false, acceptsFallback: true, scope: $scope) + : $this->translationLookup($node, choice: true, acceptsFallback: false, scope: $scope); + } +} diff --git a/src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php b/src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php new file mode 100644 index 0000000000..d033711630 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedTranslationFunctionCollector.php @@ -0,0 +1,72 @@ + + */ +#[AutowiredService] +class UsedTranslationFunctionCollector implements Collector +{ + use CollectsTranslationLookups; + + /** + * Create a new collector instance. + */ + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * Collect the translation looked up through a translation helper. + * + * @param FuncCall $node + * + * @return null|TranslationLookup + */ + public function processNode(Node $node, Scope $scope): ?array + { + if (! $node->name instanceof Name) { + return null; + } + + // Upstream resolves the name as a class name, which misses a namespace's own function + // that shadows the global helper. + $function = strtolower((string) $this->reflectionProvider->resolveFunctionName($node->name, $scope)); + + if (! in_array($function, ['__', 'trans', 'trans_choice'], true)) { + return null; + } + + return $this->translationLookup( + $node, + choice: $function === 'trans_choice', + acceptsFallback: false, + scope: $scope, + ); + } +} diff --git a/src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php b/src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php new file mode 100644 index 0000000000..c26e935422 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedTranslationTranslatorCollector.php @@ -0,0 +1,70 @@ + + */ +#[AutowiredService] +class UsedTranslationTranslatorCollector implements Collector +{ + use CollectsTranslationLookups; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Collect the translation looked up through a translator. + * + * @param MethodCall $node + * + * @return null|TranslationLookup + */ + public function processNode(Node $node, Scope $scope): ?array + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + $method = $name->toLowerString(); + + if (! in_array($method, ['get', 'choice'], true)) { + return null; + } + + $class = $node->var; + + // Upstream matches the concrete translator. Hypervel code injects the contract, + // which trans() also returns, and the concrete translator implements it. + if (! (new ObjectType(Translator::class))->isSuperTypeOf($scope->getType($class))->yes()) { + return null; + } + + return $method === 'get' + ? $this->translationLookup($node, choice: false, acceptsFallback: true, scope: $scope) + : $this->translationLookup($node, choice: true, acceptsFallback: false, scope: $scope); + } +} diff --git a/src/phpstan/src/Collectors/UsedTranslationViewCollector.php b/src/phpstan/src/Collectors/UsedTranslationViewCollector.php new file mode 100644 index 0000000000..c6ee7915ba --- /dev/null +++ b/src/phpstan/src/Collectors/UsedTranslationViewCollector.php @@ -0,0 +1,255 @@ + + (?: + \s+ + (?: + @(?:class|style)(?\((?:[^()]++|(?&parentheses))*\)) + | + \{\{\s*\$attributes(?:[^}]+?)?\s*\}\} + | + :\$\w+ + | + [\w\-:.@%]+(?:=(?:"[^"]*"|'[^']*'|[^'"=<>]+))? + ) + )* + \s* + ) + \/?> + /x + REGEXP; + + /** + * Component attributes bound with a single colon or bind:, whose values are PHP expressions. + */ + private const string BOUND_ATTRIBUTE_REGEX = '/(?[^"]*)"|\'(?[^\']*)\')/'; + + /** + * Echoes in Blade's compile order: raw, escaped, then regular. An @ prefix leaves an echo as text. + * + * @var list + */ + private const array ECHO_REGEXES = [ + '/(@)?\{!!\s*(.+?)\s*!!\}/s', + '/(@)?\{\{\{\s*(.+?)\s*\}\}\}/s', + '/(@)?\{\{\s*(.+?)\s*\}\}/s', + ]; + + /** + * Create a new collector instance. + */ + public function __construct( + private ViewParser $viewParser, + private BladeScanner $bladeScanner, + private ViewFileHelper $viewFileHelper, + private Filesystem $filesystem, + ) { + } + + /** + * Get the translations looked up by the root views, keyed by view file. + * + * @return array + */ + public function getUsedTranslations(): array + { + $translations = []; + + foreach ($this->viewFileHelper->getRootViewFilePaths() as $viewFile) { + $translations[$viewFile] = $this->processView($this->filesystem->get($viewFile)); + } + + return $translations; + } + + /** + * Get the translations looked up by the given view source. + * + * Upstream matches translation calls anywhere in a view's text, so literal text, + * JavaScript and string literals count as lookups, while native PHP does not. This + * reads only the code Blade runs, following Blade's own passes. + * + * @return list + */ + private function processView(string $source): array + { + $fragments = []; + + [$verbatimBlocks, $phpBlocks] = $this->bladeScanner->setAside($source); + + // Blade restores a verbatim block as written: its Blade syntax stays text, while its + // PHP tags still run. + foreach ($verbatimBlocks as [$code, $offset]) { + $fragments[] = [$code, 1 + substr_count($source, "\n", 0, $offset)]; + } + + foreach ($phpBlocks as [$code, $offset]) { + $fragments[] = ["", 1 + substr_count($source, "\n", 0, $offset)]; + } + + preg_match_all(self::COMPONENT_TAG_REGEX, $source, $tags, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + foreach ($tags as $tag) { + preg_match_all(self::BOUND_ATTRIBUTE_REGEX, $tag['attributes'][0], $attributes, PREG_SET_ORDER | PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL); + + foreach ($attributes as $attribute) { + [$value, $offset] = $attribute['double'][0] === null ? $attribute['single'] : $attribute['double']; + + $fragments[] = ["bladeScanner->text($source) as [$html, $line]) { + foreach ($this->textFragments($html) as [$code, $offset]) { + $fragments[] = [$code, $line + substr_count($html, "\n", 0, $offset)]; + } + } + + // Native PHP is read only when the view parses as PHP before Blade compiles it. + $translations = $this->lookups($this->viewParser->parseCode($source), 0); + + foreach ($fragments as [$code, $line]) { + // Most fragments make no translation call, so they are not parsed. + if (preg_match('/trans|__|lang/i', $code) === 1) { + array_push($translations, ...$this->lookups($this->viewParser->parseCode($code), $line - 1)); + } + } + + return $translations; + } + + /** + * Get the PHP that Blade compiles from the directives and echoes in the given text, with each fragment's offset. + * + * Directives compile before echoes, as in Blade. Each echo is blanked as it is taken, + * keeping offsets, so a later echo pattern cannot read it again. + * + * @return list + */ + private function textFragments(string $html): array + { + $fragments = []; + + foreach ($this->bladeScanner->directives($html) as [$name, $arguments, $offset]) { + $fragments[] = [$this->directiveCode($name, $arguments), $offset]; + } + + foreach (self::ECHO_REGEXES as $pattern) { + foreach ($this->bladeScanner->take($pattern, $html) as $match) { + if ($match[1][0] === null) { + $fragments[] = [" " " " " " + */ + private function lookups(array $statements, int $lineOffset): array + { + $lookups = []; + + foreach ((new NodeFinder)->findInstanceOf($statements, CallLike::class) as $call) { + // PHPStan turns a first-class callable into its own node, but views are parsed directly. + if ($call->isFirstClassCallable()) { + continue; + } + + $name = match (true) { + $call instanceof FuncCall && $call->name instanceof Name => $call->name->toLowerString(), + $call instanceof StaticCall && $call->class instanceof Name && $call->name instanceof Identifier => "{$call->class->toLowerString()}::{$call->name->toLowerString()}", + default => null, + }; + + $operation = match ($name) { + '__', 'trans' => [false, false], + 'trans_choice', 'lang::choice', 'hypervel\support\facades\lang::choice' => [true, false], + 'lang::get', 'hypervel\support\facades\lang::get' => [false, true], + default => null, + }; + + if ($operation === null) { + continue; + } + + $lookup = $this->translationLookup($call, choice: $operation[0], acceptsFallback: $operation[1], scope: null); + + if ($lookup !== null) { + $lookup['line'] = $lineOffset + $call->getStartLine(); + $lookups[] = $lookup; + } + } + + return $lookups; + } +} diff --git a/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php b/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php new file mode 100644 index 0000000000..809b1ea974 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedViewFacadeMakeCollector.php @@ -0,0 +1,65 @@ +> */ +#[AutowiredService] +class UsedViewFacadeMakeCollector implements Collector +{ + use CollectsViewNames; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Collect the view made through the View facade. + * + * @param StaticCall $node + * @return null|list + */ + public function processNode(Node $node, Scope $scope): ?array + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + if ($name->toLowerString() !== 'make') { + return null; + } + + $class = $node->class; + + if (! $class instanceof Name) { + return null; + } + + $class = $scope->resolveName($class); + + if (! (new ObjectType(View::class))->isSuperTypeOf(new ObjectType($class))->yes()) { + return null; + } + + // Upstream reads the first argument by position, which misses reordered named arguments. + return $this->getViewNames($node->getArg('view', 0)?->value, $scope); + } +} diff --git a/src/phpstan/src/Collectors/UsedViewFunctionCollector.php b/src/phpstan/src/Collectors/UsedViewFunctionCollector.php new file mode 100644 index 0000000000..f1802c4c18 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedViewFunctionCollector.php @@ -0,0 +1,61 @@ +> */ +#[AutowiredService] +class UsedViewFunctionCollector implements Collector +{ + use CollectsViewNames; + + /** + * Create a new collector instance. + */ + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * Collect the view rendered through the view() helper. + * + * @param FuncCall $node + * @return null|list + */ + public function processNode(Node $node, Scope $scope): ?array + { + $funcName = $node->name; + + if (! $funcName instanceof Name) { + return null; + } + + // Upstream compares the name as written, but function names are case-insensitive. + if (strtolower((string) $this->reflectionProvider->resolveFunctionName($funcName, $scope)) !== 'view') { + return null; + } + + // Upstream reads the first argument by position, which misses reordered named arguments. + return $this->getViewNames($node->getArg('view', 0)?->value, $scope); + } +} diff --git a/src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php b/src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php new file mode 100644 index 0000000000..6b8841a6e6 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedViewInAnotherViewCollector.php @@ -0,0 +1,241 @@ +> + */ + private const array VIEW_DIRECTIVES = [ + 'component' => ['view' => 0], + 'componentfirst' => ['names' => 0], + 'each' => ['view' => 0, 'empty' => 3], + 'extends' => ['view' => 0], + 'extendsfirst' => ['views' => 0], + 'include' => ['view' => 0], + 'includefirst' => ['views' => 0], + 'includeif' => ['view' => 0], + 'includeisolated' => ['view' => 0], + 'includeunless' => ['view' => 1], + 'includewhen' => ['view' => 1], + ]; + + /** + * The names of opening Blade component tags, such as ``, `` and + * ``, spelled as ComponentTagCompiler matches them. + * + * Upstream matches only the `x-` prefix and stops at `::`, so namespaced components lost their name. + */ + private const string COMPONENT_TAG_REGEX = '/<\s*x[-:]([\w\-:.]+)/'; + + /** + * Create a new collector instance. + */ + public function __construct( + private ViewParser $viewParser, + private BladeScanner $bladeScanner, + private ViewFileHelper $viewFileHelper, + private Filesystem $filesystem, + ) { + } + + /** + * Get the views referenced from other views. + * + * @return list + */ + public function getUsedViews(): array + { + $usedViews = []; + + foreach ($this->viewFileHelper->getAllViewFilePaths() as $viewFile) { + $usedViews = array_merge($usedViews, $this->processView($this->filesystem->get($viewFile))); + } + + return $usedViews; + } + + /** + * Get the views referenced by the given view source. + * + * Only the text Blade compiles is read, following Blade's own passes, so escaped, + * commented and verbatim directives and tags are not usages. + * + * @return list + */ + private function processView(string $source): array + { + $this->bladeScanner->setAside($source); + + $usedViews = []; + + foreach ($this->bladeScanner->text($source) as [$html]) { + foreach ($this->bladeScanner->directives($html) as [$name, $arguments]) { + $parameters = self::VIEW_DIRECTIVES[strtolower($name)] ?? null; + + if ($parameters !== null) { + $usedViews = array_merge($usedViews, $this->directiveViews($arguments, $parameters)); + } + } + + $usedViews = array_merge($usedViews, $this->componentViews($html)); + } + + return $usedViews; + } + + /** + * Get the views named by literals in a view directive's arguments. + * + * @param array $parameters + * @return list + */ + private function directiveViews(string $arguments, array $parameters): array + { + $statement = $this->viewParser->parseCode("expr instanceof FuncCall) { + return []; + } + + $usedViews = []; + + foreach ($parameters as $parameter => $position) { + $argument = $statement->expr->getArg($parameter, $position)?->value; + + $names = $argument instanceof Array_ + ? array_map(static fn (ArrayItem $item): Expr => $item->value, $argument->items) + : [$argument]; + + foreach ($names as $name) { + // renderEach() shows an empty value starting with raw| as text. + if ($name instanceof String_ && ! str_starts_with($name->value, 'raw|')) { + $usedViews[] = ViewName::normalize($name->value); + } + } + } + + return $usedViews; + } + + /** + * Get the views that Blade component tags in the given HTML may render. + * + * These are the views ComponentTagCompiler::componentClass() tries: a component alias naming + * a view, then the anonymous component views in the components directory, the registered + * anonymous component namespaces and the registered anonymous component paths. Each one + * is collected, since which of them exists is decided by the rule. Upstream only collects + * the components directory's view, so registered components were reported as unused. + * + * @return list + */ + private function componentViews(string $html): array + { + preg_match_all(self::COMPONENT_TAG_REGEX, $html, $matches); + + if ($matches[1] === []) { + return []; + } + + /** @var BladeCompiler $compiler */ + $compiler = $this->resolve('blade.compiler'); + $aliases = $compiler->getClassComponentAliases(); + $namespaces = $compiler->getAnonymousComponentNamespaces(); + $paths = $compiler->getAnonymousComponentPaths(); + $usedViews = []; + + foreach (array_unique($matches[1]) as $name) { + // Blade compiles slots before components. + if ($name === 'slot' || str_starts_with($name, 'slot:')) { + continue; + } + + if (isset($aliases[$name])) { + $usedViews[] = $aliases[$name]; + } + + $candidates = [['components', $name]]; + + foreach ($namespaces as $prefix => $directory) { + if (str_starts_with($name, $prefix . '::')) { + $candidates[] = [$directory, Str::after($name, $prefix . '::')]; + } + } + + foreach ($candidates as [$directory, $componentName]) { + $usedViews = array_merge($usedViews, $this->anonymousViews($this->guessViewName($componentName, $directory), $componentName)); + } + + foreach ($paths as $path) { + $prefixed = str_starts_with($name, $path['prefix'] . '::'); + + if (! $prefixed && str_contains($name, '::')) { + continue; + } + + $componentName = $prefixed ? Str::after($name, '::') : $name; + $usedViews = array_merge($usedViews, $this->anonymousViews($path['prefixHash'] . '::' . $componentName, $componentName)); + } + } + + return $usedViews; + } + + /** + * Get the views an anonymous component's view name may refer to: the view itself, its index view, or the view named like its directory. + * + * @return list + */ + private function anonymousViews(string $view, string $componentName): array + { + return [$view, $view . '.index', $view . '.' . Str::afterLast(Str::afterLast($componentName, '.'), ':')]; + } + + /** + * Get the view name of an anonymous component in the given directory, as ComponentTagCompiler::guessViewName() does. + */ + private function guessViewName(string $name, string $directory): string + { + $prefix = Str::finish($directory, '.'); + + return str_contains($name, '::') + ? Str::replaceFirst('::', '::' . $prefix, $name) + : $prefix . $name; + } +} diff --git a/src/phpstan/src/Collectors/UsedViewMakeCollector.php b/src/phpstan/src/Collectors/UsedViewMakeCollector.php new file mode 100644 index 0000000000..9d687aa944 --- /dev/null +++ b/src/phpstan/src/Collectors/UsedViewMakeCollector.php @@ -0,0 +1,58 @@ +> */ +#[AutowiredService] +class UsedViewMakeCollector implements Collector +{ + use CollectsViewNames; + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Collect the view made through a view factory. + * + * @param MethodCall $node + * @return null|list + */ + public function processNode(Node $node, Scope $scope): ?array + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + if ($name->toLowerString() !== 'make') { + return null; + } + + $class = $node->var; + + if (! (new ObjectType(Factory::class))->isSuperTypeOf($scope->getType($class))->yes()) { + return null; + } + + // Upstream reads the first argument by position, which misses reordered named arguments. + return $this->getViewNames($node->getArg('view', 0)?->value, $scope); + } +} diff --git a/src/phpstan/src/Collectors/WorkerStateCallCollector.php b/src/phpstan/src/Collectors/WorkerStateCallCollector.php new file mode 100644 index 0000000000..1451cf4c72 --- /dev/null +++ b/src/phpstan/src/Collectors/WorkerStateCallCollector.php @@ -0,0 +1,255 @@ +}|ConfigWrite|HelperCall|RouteAction> + */ +#[AutowiredService] +class WorkerStateCallCollector implements Collector +{ + /** + * The repository methods that change configuration. + */ + private const array CONFIG_WRITES = ['set', 'push', 'prepend', 'offsetset', 'offsetunset']; + + /** + * The route registration methods whose action parameter takes a route closure. + */ + private const array ROUTE_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'any', 'match', 'fallback', 'addroute']; + + /** + * Create a new collector instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private WorkerStateHelper $helper, + ) { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Collect the call's resolved abstracts, route closure, helper or config write. + * + * @param CallLike $node + * + * @return null|array{type: 'resolution', abstracts: list}|ConfigWrite|HelperCall|RouteAction + */ + public function processNode(Node $node, Scope $scope): ?array + { + if (! $this->helper->isEnabled() || $node->isFirstClassCallable()) { + return null; + } + + $abstracts = $this->helper->getResolvedAbstracts($node, $scope); + + if ($abstracts !== null) { + return ['type' => 'resolution', 'abstracts' => $abstracts]; + } + + $action = $this->getRouteAction($node, $scope); + + if ($action !== null) { + // A function nested in the action is called on its own terms, so it is not the action. + $nested = (new NodeFinder)->find( + $action->getStmts(), + static fn (Node $node): bool => $node instanceof FunctionLike || $node instanceof ClassLike, + ); + + return [ + 'type' => 'action', + 'start' => $action->getStartFilePos(), + 'end' => $action->getEndFilePos(), + 'nested' => array_map(static fn (Node $node): array => [$node->getStartFilePos(), $node->getEndFilePos()], $nested), + 'configParameters' => $this->getConfigParameters($action->params), + ]; + } + + // A direct call on $this runs the helper for the same instance, in the same operation. A + // nested function may run later, and creating a callable runs nothing. + if ( + $node instanceof MethodCall + && $node->var instanceof Variable + && $node->var->name === 'this' + && $node->name instanceof Identifier + && ! $scope->isInAnonymousFunction() + ) { + $function = $this->helper->getFunctionFacts($scope, $node); + + return $function === null || $function['class'] === null || $function['method'] === null + ? null + : ['type' => 'call', 'class' => $function['class'], 'method' => $function['method'], 'callee' => $node->name->name]; + } + + if (! $this->isConfigWrite($node, $scope)) { + return null; + } + + // A repository in a local or a property may be another one. + $receiver = $node instanceof MethodCall + ? $this->helper->getConfigReceiver($node->var, $scope) + : ['abstracts' => null, 'parameter' => null]; + $function = $this->helper->getFunctionFacts($scope, $node); + + if ($receiver === null || $function === null) { + return null; + } + + return ['type' => 'config', 'line' => $node->getStartLine(), ...$receiver, 'function' => $function]; + } + + /** + * Get the closure or arrow function the call registers as a route's action. + */ + private function getRouteAction(CallLike $call, Scope $scope): ArrowFunction|Closure|null + { + if (! $call instanceof MethodCall && ! $call instanceof StaticCall + || ! $call->name instanceof Identifier + || ! in_array($call->name->toLowerString(), self::ROUTE_METHODS, true)) { + return null; + } + + $type = match (true) { + $call instanceof MethodCall => $scope->getType($call->var), + $call->class instanceof Name => $scope->resolveTypeByName($call->class), + default => null, + }; + $method = $type === null ? null : $scope->getMethodReflection($type, $call->name->name); + $class = $method?->getDeclaringClass(); + + if ($class === null || ! $class->is(Router::class) && ! $class->is(RouteRegistrar::class) && ! $class->is(Route::class)) { + return null; + } + + $parameters = ParametersAcceptorSelector::selectFromArgs($scope, $call->getArgs(), $method->getVariants())->getParameters(); + + foreach ($call->getArgs() as $index => $argument) { + if (($argument->name?->toString() ?? ($parameters[$index] ?? null)?->getName()) === 'action') { + return $argument->value instanceof Closure || $argument->value instanceof ArrowFunction ? $argument->value : null; + } + } + + return null; + } + + /** + * Get the route closure parameters the router injects that are typed as a configuration repository, + * with their classes: those without a default or a contextual attribute. + * + * @param array $parameters + * + * @return array + */ + private function getConfigParameters(array $parameters): array + { + $classes = []; + + foreach ($parameters as $parameter) { + $type = $parameter->type instanceof NullableType ? $parameter->type->type : $parameter->type; + + if ($parameter->default === null + && $type instanceof Name + && $this->reflectionProvider->hasClass($type->toString()) + && $this->reflectionProvider->getClass($type->toString())->is(Repository::class) + && $parameter->var instanceof Variable + && is_string($parameter->var->name) + && ! $this->hasContextualAttribute($parameter)) { + $classes[$parameter->var->name] = $type->toString(); + } + } + + return $classes; + } + + /** + * Determine whether a contextual attribute supplies the parameter's value. + */ + private function hasContextualAttribute(Param $parameter): bool + { + foreach ($parameter->attrGroups as $group) { + if (array_any($group->attrs, fn ($attribute): bool => $this->reflectionProvider->hasClass($attribute->name->toString()) + && $this->reflectionProvider->getClass($attribute->name->toString())->is(ContextualAttribute::class))) { + return true; + } + } + + return false; + } + + /** + * Determine whether the call changes configuration: config() with an array, a Config facade write, + * or a repository write method. + */ + private function isConfigWrite(CallLike $call, Scope $scope): bool + { + if ($call instanceof FuncCall) { + $key = $call->getArg('key', 0); + + return $this->helper->isConfigFunction($call, $scope) + && $key !== null + && $scope->getType($key->value)->isArray()->yes(); + } + + if (! $call instanceof MethodCall && ! $call instanceof StaticCall + || ! $call->name instanceof Identifier + || ! in_array($call->name->toLowerString(), self::CONFIG_WRITES, true)) { + return false; + } + + // A method call writes configuration when its receiver is the repository, which the caller checks. + return ! $call instanceof StaticCall + || ($call->class instanceof Name + && (new ObjectType(Config::class))->isSuperTypeOf(new ObjectType($scope->resolveName($call->class)))->yes()); + } +} diff --git a/src/phpstan/src/Collectors/WorkerStateClassCollector.php b/src/phpstan/src/Collectors/WorkerStateClassCollector.php new file mode 100644 index 0000000000..1f28be6b4c --- /dev/null +++ b/src/phpstan/src/Collectors/WorkerStateClassCollector.php @@ -0,0 +1,91 @@ +}> + */ +#[AutowiredService] +class WorkerStateClassCollector implements Collector +{ + /** + * Create a new collector instance. + */ + public function __construct(private WorkerStateHelper $helper) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * Collect the class's root declarations and promoted state captures. + * + * @param InClassNode $node + * + * @return null|array{class: string, singleton: bool, queued: bool, captures: list} + */ + public function processNode(Node $node, Scope $scope): ?array + { + $class = $node->getClassReflection(); + + if (! $this->helper->isEnabled() || ! $class->isClass()) { + return null; + } + + $concrete = ! $class->isAbstract(); + $singleton = $concrete && $class->getNativeReflection()->getAttributes(Singleton::class) !== []; + $queued = $concrete && $class->is(ShouldQueue::class); + $captures = []; + + $constructor = $node->getOriginalNode()->getMethod('__construct'); + + // A constructor documented as boot-time or test-only keeps its parameters by design. + $parameters = $this->helper->hasLifetimeTag($constructor?->getDocComment()?->getText()) ? [] : $constructor->params ?? []; + + foreach ($parameters as $parameter) { + if ($parameter->flags === 0 || ! $parameter->var instanceof Variable || ! is_string($parameter->var->name)) { + continue; + } + + $state = $this->helper->getState($class->getNativeProperty($parameter->var->name)->getReadableType()); + + if ($state !== null) { + $captures[] = ['property' => $parameter->var->name, 'line' => $parameter->getStartLine(), 'state' => $state]; + } + } + + if (! $singleton && ! $queued && $captures === []) { + return null; + } + + return [ + 'class' => $class->getName(), + 'singleton' => $singleton, + 'queued' => $queued, + 'captures' => $captures, + ]; + } +} diff --git a/src/phpstan/src/Collectors/WorkerStateConfigOffsetCollector.php b/src/phpstan/src/Collectors/WorkerStateConfigOffsetCollector.php new file mode 100644 index 0000000000..b660da2deb --- /dev/null +++ b/src/phpstan/src/Collectors/WorkerStateConfigOffsetCollector.php @@ -0,0 +1,84 @@ + + */ +#[AutowiredService] +class WorkerStateConfigOffsetCollector implements Collector +{ + /** + * Create a new collector instance. + */ + public function __construct(private WorkerStateHelper $helper) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return Stmt::class; + } + + /** + * Collect the statement's config offset write. + * + * @param Stmt $node + * + * @return null|ConfigWrite + */ + public function processNode(Node $node, Scope $scope): ?array + { + if (! $this->helper->noRuntimeConfigMutation) { + return null; + } + + $targets = match (true) { + $node instanceof Expression && ($node->expr instanceof Assign || $node->expr instanceof AssignOp) => [$node->expr->var], + $node instanceof Unset_ => $node->vars, + default => [], + }; + + foreach ($targets as $target) { + if (! $target instanceof ArrayDimFetch) { + continue; + } + + $receiver = $this->helper->getConfigReceiver($target->var, $scope); + + if ($receiver === null) { + continue; + } + + $function = $this->helper->getFunctionFacts($scope, $target); + + return $function === null + ? null + : ['type' => 'config', 'line' => $node->getStartLine(), ...$receiver, 'function' => $function]; + } + + return null; + } +} diff --git a/src/phpstan/src/Collectors/WorkerStateParameterCollector.php b/src/phpstan/src/Collectors/WorkerStateParameterCollector.php new file mode 100644 index 0000000000..808f5306ed --- /dev/null +++ b/src/phpstan/src/Collectors/WorkerStateParameterCollector.php @@ -0,0 +1,95 @@ + + */ +#[AutowiredService] +class WorkerStateParameterCollector implements Collector +{ + /** + * Create a new collector instance. + */ + public function __construct(private WorkerStateHelper $helper) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + // @phpstan-ignore phpstanApi.classConstant (PHPStan's normalized variable write, covering destructuring, foreach and catch targets; no API node covers them.) + return VariableAssignNode::class; + } + + /** + * Collect the parameter the node reassigns. + * + * @param VariableAssignNode $node + * + * @return null|array{function: FunctionFacts, parameter: string} + */ + public function processNode(Node $node, Scope $scope): ?array + { + if (! $this->helper->isEnabled()) { + return null; + } + + // @phpstan-ignore phpstanApi.method (PHPStan's normalized variable write, covering destructuring, foreach and catch targets; no API node covers them.) + $value = $node->getAssignedExpr(); + + // An offset write on an ArrayAccess variable changes the object it holds, not the variable. + // @phpstan-ignore phpstanApi.class (The offset chain PHPStan builds inside a variable write.), phpstanApi.class (The offset chain PHPStan builds inside a variable write.), phpstanApi.class (The offset chain PHPStan builds inside a variable write.) + if ($value instanceof SetOffsetValueTypeExpr || $value instanceof SetExistingOffsetValueTypeExpr || $value instanceof UnsetOffsetExpr) { + return null; + } + + // @phpstan-ignore phpstanApi.method (PHPStan's normalized variable write, covering destructuring, foreach and catch targets; no API node covers them.) + $variable = $node->getVariable(); + $parameter = is_string($variable->name) + ? array_find($this->helper->getParameters($scope), static fn (ParameterReflection $parameter): bool => $parameter->getName() === $variable->name) + : null; + + if ($parameter === null) { + return null; + } + + $function = $this->helper->getFunctionFacts($scope, $variable); + + if ($function === null) { + return null; + } + + $constructor = ! $function['closure'] && $function['method'] === '__construct'; + + if (! $this->helper->isConfigRepositoryType($parameter->getType()) + && ! ($constructor && $this->helper->getState($parameter->getType()) !== null)) { + return null; + } + + return ['function' => $function, 'parameter' => $parameter->getName()]; + } +} diff --git a/src/phpstan/src/Collectors/WorkerStateWriteCollector.php b/src/phpstan/src/Collectors/WorkerStateWriteCollector.php new file mode 100644 index 0000000000..03dbcee923 --- /dev/null +++ b/src/phpstan/src/Collectors/WorkerStateWriteCollector.php @@ -0,0 +1,161 @@ + + */ +#[AutowiredService] +class WorkerStateWriteCollector implements Collector +{ + /** + * Create a new collector instance. + */ + public function __construct(private WorkerStateHelper $helper) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + // @phpstan-ignore phpstanApi.classConstant (PHPStan's normalized property write, which its own property rules read; no API node covers static and offset writes.) + return PropertyAssignNode::class; + } + + /** + * Collect the write's property, function and value facts. + * + * @param PropertyAssignNode $node + * + * @return null|array{static: bool, class: string, property: string, line: int, function: FunctionFacts, state: null|string, parameter: null|string, cacheKey: bool} + */ + public function processNode(Node $node, Scope $scope): ?array + { + if (! $this->helper->isEnabled()) { + return null; + } + + // @phpstan-ignore phpstanApi.method (PHPStan's normalized property write, which its own property rules read; no API node covers static and offset writes.) + $fetch = $node->getPropertyFetch(); + + if (! $fetch->name instanceof Identifier) { + return null; + } + + $static = $fetch instanceof StaticPropertyFetch; + + if ($static) { + $type = $fetch->class instanceof Name ? new ObjectType($scope->resolveName($fetch->class)) : $scope->getType($fetch->class); + $property = $scope->getStaticPropertyReflection($type, $fetch->name->name); + } elseif ($fetch->var instanceof Variable && $fetch->var->name === 'this') { + $property = $scope->getInstancePropertyReflection($scope->getType($fetch->var), $fetch->name->name); + } else { + return null; + } + + $class = $static ? $property?->getDeclaringClass() : $scope->getClassReflection(); + + if ($property === null || $class === null || $property->isVirtual()->yes()) { + return null; + } + + $function = $this->helper->getFunctionFacts($scope, $fetch); + + if ($function === null) { + return null; + } + + // @phpstan-ignore phpstanApi.method (PHPStan's normalized property write, which its own property rules read; no API node covers static and offset writes.) + $value = $node->getAssignedExpr(); + $cacheKey = false; + $storage = $scope->getType($fetch); + + // Storage the method just reset holds only what the method itself adds. + $growingStorage = $storage->isArray()->yes() && ! $storage->isConstantArray()->yes(); + + // @phpstan-ignore phpstanApi.class (The offset chain PHPStan builds inside a property write.), phpstanApi.class (The offset chain PHPStan builds inside a property write.) + while ($value instanceof SetOffsetValueTypeExpr || $value instanceof SetExistingOffsetValueTypeExpr) { + // @phpstan-ignore phpstanApi.method (The offset chain PHPStan builds inside a property write.) + $dim = $value->getDim(); + + $cacheKey = $cacheKey || ($growingStorage + && $dim !== null + && $this->helper->readsRequestState($dim, $scope) + && ! $this->helper->isBoundedKey($scope->getType($dim))); + + // @phpstan-ignore phpstanApi.method (The offset chain PHPStan builds inside a property write.) + $value = $value->getValue(); + } + + // @phpstan-ignore phpstanApi.class (The offset chain PHPStan builds inside a property write.) + $state = $value instanceof UnsetOffsetExpr ? null : $this->helper->getState($scope->getType($value)); + + // A constructor parameter holds what the container injects unless the constructor reassigns it; + // the rule checks both. + $parameter = $state !== null + && $function['method'] === '__construct' + && ! $function['closure'] + && $value instanceof Variable + && is_string($value->name) + && array_any( + $scope->getFunction()?->getOnlyVariant()->getParameters() ?? [], + static fn (ParameterReflection $parameter): bool => $parameter->getName() === $value->name, + ) ? $value->name : null; + + // A write in a closure may run in a route action, and one in an instance method in a booted + // middleware or controller action, or in a helper such a method or a job calls. + $method = $scope->getFunction(); + $candidate = $function['context'] !== null + || $function['closure'] + || ($method instanceof MethodReflection + && ! $method->isStatic() + && $method->getName() !== '__construct'); + + if ($state === null && ! $cacheKey && ! $candidate) { + return null; + } + + return [ + 'static' => $static, + 'class' => $class->getName(), + 'property' => $fetch->name->name, + 'line' => $fetch->getStartLine(), + 'function' => $function, + 'state' => $state, + 'parameter' => $parameter, + 'cacheKey' => $cacheKey, + ]; + } +} diff --git a/src/phpstan/src/Concerns/CollectsMailViews.php b/src/phpstan/src/Concerns/CollectsMailViews.php new file mode 100644 index 0000000000..5f002cfb38 --- /dev/null +++ b/src/phpstan/src/Concerns/CollectsMailViews.php @@ -0,0 +1,48 @@ + + */ + private function getMailViews(?Expr $view, Scope $scope): ?array + { + if ($view === null) { + return null; + } + + $views = $this->getViewNames($view, $scope) ?? []; + + foreach ($scope->getType($view)->getConstantArrays() as $array) { + $keys = $array->hasOffsetValueType(new ConstantIntegerType(0))->yes() + ? [new ConstantIntegerType(0), new ConstantIntegerType(1)] + : [new ConstantStringType('html'), new ConstantStringType('text')]; + + foreach ($keys as $key) { + foreach ($array->getOffsetValueType($key)->getConstantStrings() as $name) { + $views[] = ViewName::normalize($name->getValue()); + } + } + } + + return $views === [] ? null : $views; + } +} diff --git a/src/phpstan/src/Concerns/CollectsTranslationLookups.php b/src/phpstan/src/Concerns/CollectsTranslationLookups.php new file mode 100644 index 0000000000..283304415f --- /dev/null +++ b/src/phpstan/src/Concerns/CollectsTranslationLookups.php @@ -0,0 +1,119 @@ +, fallback: bool, choice: bool} + */ +trait CollectsTranslationLookups +{ + /** + * Get the lookup made by a call with a literal translation key. + * + * @param bool $choice whether the call is a choice() lookup, whose locale follows the count + * @param bool $acceptsFallback whether the call has get()'s fallback parameter + * @return null|TranslationLookup + */ + protected function translationLookup(CallLike $call, bool $choice, bool $acceptsFallback, ?Scope $scope): ?array + { + foreach ($call->getArgs() as $argument) { + // Arguments after an unpacked one have unknown positions. + if ($argument->unpack) { + return null; + } + } + + $key = $call->getArg('key', 0)?->value; + + if (! $key instanceof String_) { + return null; + } + + $locales = $this->translationLocales($call->getArg('locale', $choice ? 3 : 2)?->value, $scope); + + if ($locales === null) { + return null; + } + + $fallback = ! $acceptsFallback || $this->translationFallback($call->getArg('fallback', 3)?->value, $scope); + + return [ + 'key' => $key->value, + 'line' => $call->getStartLine(), + 'locales' => $locales, + 'fallback' => $fallback, + 'choice' => $choice, + ]; + } + + /** + * Get the locales a lookup can use, where null is the translator's current locale. + * + * @return null|list + */ + protected function translationLocales(?Expr $locale, ?Scope $scope): ?array + { + if ($locale === null) { + return [null]; + } + + if ($scope === null) { + return match (true) { + $locale instanceof String_ => [$locale->value], + $locale instanceof ConstFetch && $locale->name->toLowerString() === 'null' => [null], + default => null, + }; + } + + $type = $scope->getType($locale); + $strings = TypeCombinator::removeNull($type)->getConstantStrings(); + + if ($strings === []) { + return $type->isNull()->yes() ? [null] : null; + } + + $locales = array_map(static fn (ConstantStringType $string): string => $string->getValue(), $strings); + + if ($type->isNull()->maybe()) { + $locales[] = null; + } + + return $locales; + } + + /** + * Determine whether a lookup can use the fallback locale. + * + * An unknown value counts as true: a translation missing with the fallback + * locale is also missing without it. + */ + protected function translationFallback(?Expr $fallback, ?Scope $scope): bool + { + if ($fallback === null) { + return true; + } + + if ($scope === null) { + return ! ($fallback instanceof ConstFetch && $fallback->name->toLowerString() === 'false'); + } + + return ! $scope->getType($fallback)->isFalse()->yes(); + } +} diff --git a/src/phpstan/src/Concerns/CollectsViewNames.php b/src/phpstan/src/Concerns/CollectsViewNames.php new file mode 100644 index 0000000000..a61bd3bb51 --- /dev/null +++ b/src/phpstan/src/Concerns/CollectsViewNames.php @@ -0,0 +1,38 @@ + + */ + private function getViewNames(?Expr $view, Scope $scope): ?array + { + if ($view === null) { + return null; + } + + $names = array_map( + static fn (ConstantStringType $name): string => ViewName::normalize($name->getValue()), + $scope->getType($view)->getConstantStrings(), + ); + + return $names === [] ? null : $names; + } +} diff --git a/src/phpstan/src/Concerns/HasContainer.php b/src/phpstan/src/Concerns/HasContainer.php new file mode 100644 index 0000000000..cc35b46655 --- /dev/null +++ b/src/phpstan/src/Concerns/HasContainer.php @@ -0,0 +1,45 @@ +container = $container; + } + + /** + * Return the current container. + */ + public function getContainer(): ContainerContract + { + return $this->container ?? Container::getInstance(); + } + + /** + * Resolve the given type from the container. + */ + public function resolve(string $abstract): mixed + { + try { + $concrete = $this->getContainer()->make($abstract); + } catch (Throwable) { + return null; + } + + return $concrete; + } +} diff --git a/src/phpstan/src/Concerns/InfersBoundHelperCalls.php b/src/phpstan/src/Concerns/InfersBoundHelperCalls.php new file mode 100644 index 0000000000..adbeda63de --- /dev/null +++ b/src/phpstan/src/Concerns/InfersBoundHelperCalls.php @@ -0,0 +1,53 @@ +getUnpackedHelperType($functionReflection, $functionCall, $scope) + ?? ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $functionReflection->getVariants(), + )->getReturnType(); + + $contractType = new ObjectType($contract); + + if ($contractType->isSuperTypeOf($returnType)->no()) { + return $returnType; + } + + return TypeCombinator::union(TypeCombinator::remove($returnType, $contractType), $boundType()); + } +} diff --git a/src/phpstan/src/Concerns/InfersFactoryHelperCalls.php b/src/phpstan/src/Concerns/InfersFactoryHelperCalls.php new file mode 100644 index 0000000000..a574bb85c3 --- /dev/null +++ b/src/phpstan/src/Concerns/InfersFactoryHelperCalls.php @@ -0,0 +1,39 @@ +getArgs(); + + if ($arguments === []) { + return $factoryType(); + } + + foreach ($arguments as $argument) { + if (! $argument->unpack) { + return $resultType; + } + } + + return TypeCombinator::union($factoryType(), $resultType); + } +} diff --git a/src/phpstan/src/Concerns/InfersUnpackedHelperCalls.php b/src/phpstan/src/Concerns/InfersUnpackedHelperCalls.php new file mode 100644 index 0000000000..c0f1b608d0 --- /dev/null +++ b/src/phpstan/src/Concerns/InfersUnpackedHelperCalls.php @@ -0,0 +1,59 @@ +getArgs(); + + if ($arguments === [] || ! $arguments[0]->unpack) { + return null; + } + + // PHP forbids positional arguments after an unpack, so the leading unpacks supply the first + // parameter unless all of them are empty. + foreach ($arguments as $argument) { + if (! $argument->unpack) { + break; + } + + $type = $scope->getType($argument->value); + + if (! $type->isList()->yes() || $type->isIterableAtLeastOnce()->yes()) { + return null; + } + } + + $variants = $functionReflection->getVariants(); + + return TypeCombinator::union( + ParametersAcceptorSelector::selectFromArgs($scope, $arguments, $variants)->getReturnType(), + $defaultType ?? ParametersAcceptorSelector::selectFromArgs($scope, [], $variants)->getReturnType(), + ); + } +} diff --git a/src/phpstan/src/Concerns/InspectsQueuedJobs.php b/src/phpstan/src/Concerns/InspectsQueuedJobs.php new file mode 100644 index 0000000000..e2bb9a696d --- /dev/null +++ b/src/phpstan/src/Concerns/InspectsQueuedJobs.php @@ -0,0 +1,272 @@ +getTraits(true)[$traitName]); + } + + /** + * Determine whether the class can be dispatched as it stands. + * + * Interfaces and traits are not classes, and an abstract class is never + * dispatched directly: a concrete subclass supplies (or inherits) whatever the + * rule asks for, and is checked on its own. + */ + private function isDispatchableClass(ClassReflection $classReflection): bool + { + return ! $classReflection->isInterface() + && ! $classReflection->isTrait() + && ! $classReflection->isAbstract(); + } + + /** + * Determine whether the static call target names the given facade. + * + * Matches the facade itself, a subclass of it, and the root namespace alias + * Hypervel registers for it (`\Bus`, `\DB`, ...). + */ + private function isFacade(Name $class, string $facade, string $alias): bool + { + $className = $class->toString(); + + if ($className === $facade || $className === $alias) { + return true; + } + + return (new ObjectType($facade))->isSuperTypeOf(new ObjectType($className))->yes(); + } + + /** + * Get the lowercase name of the method the call makes when it is one of the given + * facade or receiver methods, or null for any other call. + * + * @param array}> $facadeMethods the facade and its methods, keyed by the facade's alias + * @param array> $receiverMethods the methods each receiver type provides + */ + private function getQueueMethod(CallLike $node, Scope $scope, array $facadeMethods, array $receiverMethods): ?string + { + if ($node instanceof StaticCall) { + if (! $node->class instanceof Name || ! $node->name instanceof Identifier) { + return null; + } + + $method = $node->name->toLowerString(); + + foreach ($facadeMethods as $alias => [$facade, $methods]) { + if (in_array($method, $methods, true) && $this->isFacade($node->class, $facade, $alias)) { + return $method; + } + } + + return null; + } + + // PHPStan also analyzes a nullsafe call as a MethodCall on the non-null receiver. + if ($node instanceof MethodCall && $node->name instanceof Identifier) { + $method = $node->name->toLowerString(); + $receiverType = TypeCombinator::removeNull($scope->getType($node->var)); + + foreach ($receiverMethods as $receiver => $methods) { + if (in_array($method, $methods, true) && (new ObjectType($receiver))->isSuperTypeOf($receiverType)->yes()) { + return $method; + } + } + } + + return null; + } + + /** + * Get the jobs a bulk or batch method receives, each with the node to report it at. + * + * An array literal gives each element, recursing into nested arrays (chains within a + * batch). Any other value gives the jobs of its iterable type at the call, or its own + * type for a single job, which the entry points wrap. + * + * @return list + */ + private function getQueuedJobTypes(Expr $jobs, CallLike $node, Scope $scope): array + { + if (! $jobs instanceof Array_) { + $type = $scope->getType($jobs); + $jobTypes = []; + + foreach ($type->isIterable()->yes() ? $this->getIterableJobTypes($type) : [$type] as $jobType) { + $jobTypes[] = [$jobType, $node]; + } + + return $jobTypes; + } + + $jobTypes = []; + + foreach ($jobs->items as $item) { + if ($item->value instanceof Array_) { + foreach ($this->getQueuedJobTypes($item->value, $node, $scope) as $jobType) { + $jobTypes[] = $jobType; + } + + continue; + } + + $type = $scope->getType($item->value); + + foreach ($item->unpack ? $this->getIterableJobTypes($type) : $this->getEntryJobTypes($type) as $jobType) { + $jobTypes[] = [$jobType, $item->value]; + } + } + + return $jobTypes; + } + + /** + * Get the job types of an iterable's entries. + * + * Each value of a constant array counts on its own, so one job's class cannot hide another's. + * A choice between arrays stays one entry, which getMatchingJobs() follows path by path. + * + * @return list + */ + private function getIterableJobTypes(Type $iterable): array + { + if ($iterable instanceof UnionType && $iterable->isArray()->yes()) { + return [$iterable]; + } + + if ($iterable->isConstantArray()->yes()) { + $entries = []; + + foreach ($iterable->getConstantArrays() as $array) { + foreach ($array->getValueTypes() as $valueType) { + $entries[] = $valueType; + } + } + } else { + $entries = [$iterable->getIterableValueType()]; + } + + $jobTypes = []; + + foreach ($entries as $entry) { + foreach ($this->getEntryJobTypes($entry) as $jobType) { + // Equal jobs are reported once at the call. + $jobTypes[$jobType->describe(VerbosityLevel::cache())] = $jobType; + } + } + + return array_values($jobTypes); + } + + /** + * Get the job types of an entry: the job itself, or the jobs of a chain within a batch. + * + * Bulk dispatch rejects arrays, so reading them as chains there changes nothing it accepts. + * + * @return list + */ + private function getEntryJobTypes(Type $entry): array + { + // A choice that includes a chain stays one entry, which getMatchingJobs() follows path by path. + if ($entry instanceof UnionType && ! $entry->isArray()->no()) { + return [$entry]; + } + + if ($entry->isArray()->yes()) { + return $this->getIterableJobTypes($entry); + } + + return [$entry]; + } + + /** + * Get the jobs of an entry that match, or none when the match is not certain. + * + * A chain or a list matches through any of its jobs. A choice that includes a chain matches when + * every alternative does, or when any does unless the match must hold on every path. + * + * @param callable(Type): bool $matches + * @return list + */ + private function getMatchingJobs(Type $entry, callable $matches, bool $onEveryPath): array + { + if ($entry instanceof UnionType && ! $entry->isArray()->no()) { + $matched = []; + + foreach ($entry->getTypes() as $alternative) { + $jobs = $this->getMatchingJobs($alternative, $matches, $onEveryPath); + + if ($jobs === [] && $onEveryPath) { + return []; + } + + $matched = [...$matched, ...$jobs]; + } + + return $matched; + } + + if ($entry->isArray()->yes()) { + $matched = []; + + foreach ($this->getIterableJobTypes($entry) as $job) { + $matched = [...$matched, ...$this->getMatchingJobs($job, $matches, $onEveryPath)]; + } + + return $matched; + } + + return $matches($entry) ? [$entry] : []; + } + + /** + * Get the class names of the jobs, each once. + * + * @param list $jobs + */ + private function describeJobClasses(array $jobs): string + { + $names = []; + + foreach ($jobs as $job) { + foreach ($job->getObjectClassNames() as $name) { + $names[$name] = true; + } + } + + return implode('|', array_keys($names)); + } +} diff --git a/src/phpstan/src/Concerns/LoadsAuthModel.php b/src/phpstan/src/Concerns/LoadsAuthModel.php new file mode 100644 index 0000000000..2fc3d2701c --- /dev/null +++ b/src/phpstan/src/Concerns/LoadsAuthModel.php @@ -0,0 +1,99 @@ + $guardNames + * @return list + */ + private function getAuthModels(AuthManager $manager, ConfigRepository $config, ?array $guardNames = null): array + { + $guards = $config->get('auth.guards'); + $providers = $config->get('auth.providers'); + + if (! is_array($guards) || ! is_array($providers)) { + return []; + } + + /** @var array{array, array} $customCreators */ + $customCreators = (fn (): array => [$this->customCreators, $this->customProviderCreators])->call($manager); + $authModels = []; + + foreach ($guardNames ?? array_keys($guards) as $guardName) { + $authModel = $this->getGuardAuthModel($guards[$guardName] ?? null, $providers, ...$customCreators); + + if ($authModel === null) { + return []; + } + + if (! in_array($authModel, $authModels, strict: true)) { + $authModels[] = $authModel; + } + } + + return $authModels; + } + + /** + * Get the user class the given guard configuration's provider returns. + * + * @param array $providers + * @param array $guardCreators + * @param array $providerCreators + * @return ?class-string + */ + private function getGuardAuthModel(mixed $guard, array $providers, array $guardCreators, array $providerCreators): ?string + { + $provider = is_array($guard) ? ($guard['provider'] ?? null) : null; + + if (! is_string($provider) || $provider === '' || ! is_array($providers[$provider] ?? null)) { + return null; + } + + $driver = $providers[$provider]['driver'] ?? null; + + // A custom provider creator replaces the built-in provider of the same name. + if (! is_string($driver) || isset($providerCreators[$driver])) { + return null; + } + + $guardDriver = $guard['driver'] ?? null; + $model = $providers[$provider]['model'] ?? null; + + return match ($driver) { + 'eloquent' => is_string($model) && $model !== '' ? $model : null, + // Other guards can return users from elsewhere: Sanctum accepts its session + // guards' users when its provider, like the database one, has no model. + 'database' => in_array($guardDriver, ['session', 'token'], true) && ! isset($guardCreators[$guardDriver]) + ? GenericUser::class + : null, + default => null, + }; + } +} diff --git a/src/phpstan/src/GlobalInputsResultCacheMetaExtension.php b/src/phpstan/src/GlobalInputsResultCacheMetaExtension.php new file mode 100644 index 0000000000..3f89fc45f8 --- /dev/null +++ b/src/phpstan/src/GlobalInputsResultCacheMetaExtension.php @@ -0,0 +1,83 @@ + $configuredDirectories + * @param list $migrationDirectories + * @param list $schemaDirectories + */ + public function __construct( + private HypervelStubFilesExtension $stubFilesExtension, + private FileHelper $fileHelper, + #[AutowiredParameter(ref: '%configDirectories%')] + private array $configuredDirectories, + #[AutowiredParameter(ref: '%databaseMigrationsPath%')] + private array $migrationDirectories, + #[AutowiredParameter(ref: '%squashedMigrationsPath%')] + private array $schemaDirectories, + ) { + } + + /** + * Get the metadata key. + */ + public function getKey(): string + { + return 'hypervel.globalInputs'; + } + + /** + * Get the hash of the stub list, the configured config, migration and schema directories, and the boot-identity variables. + */ + public function getHash(): string + { + $stubFiles = $this->stubFilesExtension->getFiles(); + sort($stubFiles); + + return hash('xxh128', implode("\0", [ + implode("\n", $stubFiles), + implode("\n", DirectoryResolver::resolve($this->configuredDirectories, $this->fileHelper)), + implode("\n", DirectoryResolver::resolve($this->migrationDirectories, $this->fileHelper)), + implode("\n", DirectoryResolver::resolve($this->schemaDirectories, $this->fileHelper)), + EnvironmentSnapshot::digest(self::BOOT_VARIABLES, self::BOOT_VARIABLE_PATTERNS), + ])); + } +} diff --git a/src/phpstan/src/HypervelStubFilesExtension.php b/src/phpstan/src/HypervelStubFilesExtension.php new file mode 100644 index 0000000000..b83319e802 --- /dev/null +++ b/src/phpstan/src/HypervelStubFilesExtension.php @@ -0,0 +1,30 @@ +files()->name('*.stub')->in(__DIR__ . '/../stubs/common') as $stubFile) { + $files[] = $stubFile->getRealPath(); + } + + return $files; + } +} diff --git a/src/phpstan/src/Internal/ConfigHelper.php b/src/phpstan/src/Internal/ConfigHelper.php new file mode 100644 index 0000000000..1e89817759 --- /dev/null +++ b/src/phpstan/src/Internal/ConfigHelper.php @@ -0,0 +1,237 @@ +getArgs(), static fn (Arg $argument): bool => $argument->unpack)) { + return null; + } + + $key = $call->getArg('key', 0); + + if ($key === null) { + return null; + } + + $keyType = $scope->getType($key->value); + + if (! $keyType->isString()->yes() || ! $keyType->isConstantScalarValue()->yes()) { + return null; + } + + $default = $call->getArg('default', 1); + $defaultType = $default === null ? new NullType : TypeTraverser::map( + $scope->getType($default->value), + static function (Type $type, callable $traverse) use ($scope): Type { + // value() calls a default that is itself a Closure, not one the default holds. + if ($type instanceof UnionType) { + return $traverse($type); + } + + return (new ObjectType(Closure::class))->isSuperTypeOf($type)->yes() + ? $type->getCallableParametersAcceptors($scope)[0]->getReturnType() + : $type; + }, + )->generalize(GeneralizePrecision::lessSpecific()); + + $types = []; + + foreach ($keyType->getConstantStrings() as $constantString) { + $value = $this->configParser->getValue($constantString->getValue(), $scope); + + if ($value === null) { + return null; + } + + [$presence, $type] = $value; + + if (! $presence->no()) { + $types[] = $type; + } + + if (! $presence->yes()) { + $types[] = $defaultType; + } + } + + return TypeCombinator::union(...$types); + } + + /** + * Get the type a repository or Config facade method returns, or null when the + * declared type applies. + * + * The typed getters throw for a value of another type, so a value that never has + * the getter's type gives an error type, which ConfigTypeRule and ConfigCollectionRule + * report. + * + * @param DependencyTracker&Scope $scope + */ + public function getMethodReturnType(CallLike $call, Scope $scope): ?Type + { + $methodName = $this->getMethodName($call, $scope); + + if ($methodName === null) { + return null; + } + + $valueType = $this->getValueType($call, $scope); + + if ($valueType === null || $methodName === 'get') { + return $valueType; + } + + $type = TypeCombinator::intersect($valueType, $this->getGetterType($methodName)); + + if ($type instanceof NeverType) { + return new ErrorType; + } + + if ($methodName === 'collection') { + return new GenericObjectType(Collection::class, [$type->getIterableKeyType(), $type->getIterableValueType()]); + } + + return $type; + } + + /** + * Get the name of the standard repository or Config facade value method the call makes, + * or null when it calls another method or a custom implementation. + */ + public function getMethodName(CallLike $call, Scope $scope): ?string + { + if ((! $call instanceof MethodCall && ! $call instanceof StaticCall) || ! $call->name instanceof Identifier) { + return null; + } + + $methodName = $call->name->toLowerString(); + + if (! in_array($methodName, self::METHODS, true)) { + return null; + } + + if ($call instanceof MethodCall) { + [$calledOnType, $receiverClass] = [$scope->getType($call->var), Repository::class]; + } elseif ($call->class instanceof Name) { + [$calledOnType, $receiverClass] = [new ObjectType($scope->resolveName($call->class)), Config::class]; + } else { + return null; + } + + if (! (new ObjectType($receiverClass))->isSuperTypeOf($calledOnType)->yes()) { + return null; + } + + // The repository's typed getters read through get(), and collection() through array(), + // so a receiver overriding either changes what they return. Facade calls forward to the + // bound repository, so a facade subclass's own methods do not change them. + $methodNames = match (true) { + $call instanceof StaticCall, $methodName === 'get' => [$methodName], + $methodName === 'collection' => [$methodName, 'array', 'get'], + default => [$methodName, 'get'], + }; + + foreach ($methodNames as $name) { + $method = $scope->getMethodReflection($calledOnType, $name); + + if ($method === null || ! $this->isConfigMethod($method)) { + return null; + } + } + + return $methodName; + } + + /** + * Determine whether the method is one of the standard repository's, its contract's or the + * Config facade's value methods. A custom implementation keeps its declared return type. + */ + public function isConfigMethod(MethodReflection $method): bool + { + return in_array(strtolower($method->getName()), self::METHODS, true) + && in_array($method->getDeclaringClass()->getName(), [Repository::class, ConfigRepository::class, Config::class], true); + } + + /** + * Get the type a typed getter requires its value to have. + */ + public function getGetterType(string $methodName): Type + { + return match ($methodName) { + 'string' => new StringType, + 'integer' => new IntegerType, + 'float' => new FloatType, + 'boolean' => new BooleanType, + 'array', 'collection' => new ArrayType(new MixedType, new MixedType), + default => throw new InvalidArgumentException(sprintf('[%s] is not a typed config getter.', $methodName)), + }; + } +} diff --git a/src/phpstan/src/Internal/ConsoleApplicationHelper.php b/src/phpstan/src/Internal/ConsoleApplicationHelper.php new file mode 100644 index 0000000000..6cfa2c6223 --- /dev/null +++ b/src/phpstan/src/Internal/ConsoleApplicationHelper.php @@ -0,0 +1,122 @@ + $commands + */ + public function getArguments(array $commands, Scope $scope): ?Type + { + $argTypes = []; + + foreach ($commands as $command) { + $arguments = $command->getDefinition()->getArguments(); + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($arguments as $name => $argument) { + $argumentType = $this->getArgumentType($scope, $argument); + $builder->setOffsetValueType(new ConstantStringType($name), $argumentType); + } + + $argTypes[] = $builder->getArray(); + } + + return count($argTypes) > 0 ? TypeCombinator::union(...$argTypes) : null; + } + + /** + * Get the type of the given command argument. + */ + public function getArgumentType(Scope $scope, InputArgument $argument): Type + { + if ($argument->isArray()) { + $argType = new ArrayType(new IntegerType, new StringType); + + if (! $argument->isRequired() && $argument->getDefault() !== []) { + $argType = TypeCombinator::union($argType, $scope->getTypeFromValue($argument->getDefault())); + } + } else { + $argType = new StringType; + + if (! $argument->isRequired()) { + $argType = TypeCombinator::union($argType, $scope->getTypeFromValue($argument->getDefault())); + } + } + + return $argType; + } + + /** + * Get the options array type of the given commands. + * + * @param array $commands + */ + public function getOptions(array $commands, Scope $scope): ?Type + { + $optTypes = []; + + foreach ($commands as $command) { + $options = $command->getDefinition()->getOptions(); + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($options as $name => $option) { + $argumentType = $this->getOptionType($scope, $option); + $builder->setOffsetValueType(new ConstantStringType($name), $argumentType); + } + + $optTypes[] = $builder->getArray(); + } + + return count($optTypes) > 0 ? TypeCombinator::union(...$optTypes) : null; + } + + /** + * Get the type of the given command option. + */ + public function getOptionType(Scope $scope, InputOption $option): Type + { + if (! $option->acceptValue()) { + if ($option->isNegatable()) { + return TypeCombinator::addNull(new BooleanType); + } + + return new BooleanType; + } + + $optType = TypeCombinator::union(new StringType, new NullType); + + if ($option->isValueRequired() && ($option->isArray() || $option->getDefault() !== null)) { + $optType = TypeCombinator::removeNull($optType); + } + + if ($option->isArray()) { + $optType = new ArrayType(new IntegerType, $optType); + } + + return TypeCombinator::union($optType, $scope->getTypeFromValue($option->getDefault())); + } +} diff --git a/src/phpstan/src/Internal/ConsoleApplicationResolver.php b/src/phpstan/src/Internal/ConsoleApplicationResolver.php new file mode 100644 index 0000000000..885ca4e65a --- /dev/null +++ b/src/phpstan/src/Internal/ConsoleApplicationResolver.php @@ -0,0 +1,174 @@ + */ + private ?array $commands = null; + + /** @var array> */ + private array $commandsByClass = []; + + /** + * The files each command class's definition comes from. + * + * @var array> + */ + private array $commandFiles = []; + + /** + * Create a new resolver instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Find the registered commands that are instances of the given class. + * + * @return array keyed by object ID + */ + public function findCommands(ClassReflection $classReflection): array + { + $className = $classReflection->getName(); + + if (array_key_exists($className, $this->commandsByClass)) { + return $this->commandsByClass[$className]; + } + + // The base command stands for every command, and closure commands share one class + // across unrelated signatures, so neither identifies a definition. + if (! $classReflection->is(Command::class) || $className === Command::class || $className === ClosureCommand::class) { + return $this->commandsByClass[$className] = []; + } + + $commands = []; + + foreach ($this->getCommands() as $command) { + $id = spl_object_id($command); + + // Each alias lists a command again, under a key that need not be its own name. + if (isset($commands[$id]) || ! is_a($command, $className)) { + continue; + } + + // Upstream merges the application definition only for single-name lookups, so bulk + // shapes depended on analysis order. Merge it once, as run() does before binding input. + $command->mergeApplicationDefinition(); + + $commands[$id] = $command; + } + + return $this->commandsByClass[$className] = $commands; + } + + /** + * Find the registered commands the given receiver type can be. + * + * @param DependencyTracker&Scope $scope + * @return array keyed by object ID + */ + public function findCommandsForType(Type $type, Scope $scope): array + { + $this->runtimeInputs->track($scope); + + $commands = []; + + foreach ($type->getObjectClassReflections() as $classReflection) { + $classCommands = $this->findCommands($classReflection); + + // An alternative with no known definition could define any name, so the other + // alternatives cannot describe the receiver. + if ($classCommands === []) { + return []; + } + + $commands += $classCommands; + } + + // A definition comes from property defaults and method bodies, which are not declarations, in + // classes the boot need not load. + foreach ($commands as $command) { + foreach ($this->commandFiles[$command::class] ??= SourceFiles::forInstance($this->reflectionProvider, $this->reflectionProvider->getClass($command::class), Command::class) as $file) { + $scope->trackFileDependency($this->runtimeInputs->sourcePath($file)); + } + } + + return $commands; + } + + /** + * Get the commands registered with the console. + * + * Commands registered through the console kernel (`withCommands()` classes and discovered + * paths, and `command()` closures) are only added to the kernel's own Artisan application, + * so ask the kernel. + * + * @return array + */ + private function getCommands(): array + { + if ($this->commands !== null) { + return $this->commands; + } + + $kernel = $this->resolve(Kernel::class); + + if ($kernel instanceof Kernel) { + try { + return $this->commands = $kernel->all(); + } catch (Throwable) { + // A command that cannot be resolved during analysis stops the kernel listing any. A + // standalone application still sees the commands registered through Artisan::starting(). + } + } + + return $this->commands = $this->getApplication()->all(); + } + + /** + * Get a standalone console application. + */ + private function getApplication(): Application + { + if ($this->application === null) { + $this->application = new Application(app(), app(Dispatcher::class), app()->version()); + $this->application->setContainerCommandLoader(); + } + + return $this->application; + } +} diff --git a/src/phpstan/src/Internal/RecursionGuard.php b/src/phpstan/src/Internal/RecursionGuard.php new file mode 100644 index 0000000000..f1dfe54c16 --- /dev/null +++ b/src/phpstan/src/Internal/RecursionGuard.php @@ -0,0 +1,43 @@ + */ + private static array $context = []; + + /** + * Run the callback, or return null when the key is already running. + * + * @param callable(): T $callback + * + * @return null|T + * + * @template T + */ + public static function run(string $key, callable $callback): mixed + { + if (isset(self::$context[$key])) { + return null; + } + + try { + self::$context[$key] = true; + + return $callback(); + } finally { + unset(self::$context[$key]); + } + } +} diff --git a/src/phpstan/src/Methods/BuilderDependencies.php b/src/phpstan/src/Methods/BuilderDependencies.php new file mode 100644 index 0000000000..40d14b610a --- /dev/null +++ b/src/phpstan/src/Methods/BuilderDependencies.php @@ -0,0 +1,86 @@ +, ?string}> */ + private array $inputs = []; + + /** + * Create a builder dependency tracker. + */ + public function __construct( + private readonly DeclarationDependencyTracker $declarationDependencyTracker, + private readonly BuilderHelper $builderHelper, + ) { + } + + /** + * Declare that the owner's forwarded methods depend on the builder's custom declarations and passthru methods. + */ + public function track(ClassReflection $owner, ClassReflection $builder): void + { + // Keyed by instantiation: the query class can come from the builder's active template types. + [$classNames, $passthruFile] = $this->inputs[$builder->getCacheKey()] ??= $this->resolveInputs($builder); + + foreach ($classNames as $className) { + if ($className !== $owner->getName()) { + $this->declarationDependencyTracker->trackClassDependency($owner, $className); + } + } + + if ($passthruFile !== null) { + $this->declarationDependencyTracker->trackFileDependency($owner, $passthruFile); + } + } + + /** + * Resolve the custom builder and query classes, and the file declaring a custom passthru list. + * + * @return array{list, ?string} + */ + private function resolveInputs(ClassReflection $builder): array + { + $classNames = []; + + if ($builder->getName() !== EloquentBuilder::class) { + $classNames[] = $builder->getName(); + } + + // Ordinary calls run on the stored query and passthru calls on toBase()'s. + foreach ($this->builderHelper->queryTypes($builder) as $queryType) { + foreach ($queryType->getObjectClassNames() as $queryClassName) { + if ($queryClassName !== QueryBuilder::class && ! in_array($queryClassName, $classNames, true)) { + $classNames[] = $queryClassName; + } + } + } + + // A property default is not part of a class's declarations. + $property = $builder->getNativeProperty('passthru'); + $source = $property->getDeclaringTrait() ?? $property->getDeclaringClass(); + $passthruFile = $source->getName() === EloquentBuilder::class ? null : $source->getFileName(); + + return [$classNames, $passthruFile]; + } +} diff --git a/src/phpstan/src/Methods/BuilderHelper.php b/src/phpstan/src/Methods/BuilderHelper.php new file mode 100644 index 0000000000..d0a74afe6d --- /dev/null +++ b/src/phpstan/src/Methods/BuilderHelper.php @@ -0,0 +1,337 @@ +> */ + private array $passthru = []; + + /** @var array */ + private array $builders = []; + + /** @var array */ + private array $modelQueryBuilders = []; + + /** @var array */ + private array $builderTypes = []; + + private readonly OutOfClassScope $scope; + + /** + * Create a builder helper. + */ + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly DeclarationDependencyTracker $declarationDependencyTracker, + private readonly AttributeClassNameResolver $attributeClassNames, + ) { + $this->scope = new OutOfClassScope; + } + + /** + * Determine whether the builder passes the method through to its base query. + */ + public function isPassthru(ClassReflection $builderClass, string $methodName): bool + { + $className = $builderClass->getName(); + + if (! isset($this->passthru[$className])) { + /** @var list $passthru */ + $passthru = $builderClass->getNativeReflection()->getDefaultProperties()['passthru']; + + $this->passthru[$className] = $passthru; + } + + return in_array(strtolower($methodName), $this->passthru[$className], strict: true); + } + + /** + * Return the declared types of the queries a builder forwards to: its stored query, which ordinary + * forwarded calls run on, and the query passthru calls run on. + * + * Builder::__call() passes passthru calls to toBase() and forwards others to the stored query. Read + * through getMethod(), so a `@method` tag counts, as packages replacing the query layer (such as + * mongodb/laravel-mongodb) declare it on toBase(). + * + * @return array{Type, Type} + */ + public function queryTypes(ClassReflection $builderClass): array + { + $storedType = $builderClass->getMethod('getQuery', $this->scope)->getVariants()[0]->getReturnType(); + $baseType = $builderClass->getMethod('toBase', $this->scope)->getVariants()[0]->getReturnType(); + + // The inherited toBase() returns the scoped stored query, so its declaration describes that query too. + if ($builderClass->getNativeMethod('toBase')->getDeclaringClass()->getName() === EloquentBuilder::class) { + $storedType = TypeCombinator::intersect($storedType, $baseType); + + return [$storedType, $storedType]; + } + + return [$storedType, $baseType]; + } + + /** + * Bind a forwarded query's row types to the model, without changing raw-query types. + */ + public function bindQueryRows(Type $queryType, Type $modelType): Type + { + $queryClasses = $queryType->getObjectClassReflections(); + + if (count($queryClasses) !== 1) { + return $queryType; + } + + $queryClass = $queryClasses[0]; + $templates = $queryClass->getTemplateTypeMap(); + + if ($templates->getType('TKey') === null || $templates->getType('TValue') === null) { + return $queryType; + } + + $types = $queryClass->getActiveTemplateTypeMap()->map( + static fn (string $name, Type $type): Type => match ($name) { + 'TKey' => new IntegerType, + 'TValue' => $modelType, + default => $type, + }, + ); + + return new GenericObjectType($queryClass->getName(), $queryClass->typeMapToList($types)); + } + + /** + * Return the builder type holding the model, as Model::newModelQuery() binds it through setModel(). + * + * setModel() rebinds only the template mapped to the Eloquent builder's model, so other type arguments + * and a builder that fixes its model keep their declarations. + */ + public function getBuilderType(Type $builderType, Type $modelType): Type + { + // The base builder holds the model as given. Stub validation relies on this too: it reflects only the + // stubs, whose Builder declares no setModel(). + if ($builderType->getObjectClassNames() === [EloquentBuilder::class]) { + return new GenericObjectType(EloquentBuilder::class, [$modelType]); + } + + // Every builder-of resolution binds a model, so the inference is memoized. + $cacheKey = $builderType->describe(VerbosityLevel::cache()) . "\0" . $modelType->describe(VerbosityLevel::cache()); + + return $this->builderTypes[$cacheKey] ??= ParametersAcceptorSelector::selectFromTypes( + [$modelType], + $builderType->getMethod('setModel', $this->scope)->getVariants(), + false, + )->getReturnType(); + } + + /** + * Determine the builder Model::newQuery() returns, which query(), forwarded calls and relations run. + */ + private function determineBuilder(ClassReflection $modelReflection): string|Type + { + return $this->overridingBuilder($modelReflection, 'newQuery') + ?? ($this->modelQueryBuilders[$modelReflection->getName()] ??= $this->determineModelQueryBuilder($modelReflection)); + } + + /** + * Determine the builder Model::newModelQuery() returns, which newQuery() and the unscoped queries build on. + * + * Without an override, this is the builder Model::newEloquentBuilder() creates: an override's declared + * return, else a builder named by the UseEloquentBuilder attribute, else the $builder default (which + * upstream ignores). + */ + private function determineModelQueryBuilder(ClassReflection $modelReflection): string|Type + { + // The base model, which stands for an unknown model, creates the base builder. Stub validation + // relies on this too: it reflects only the stubs, whose Model declares no newEloquentBuilder(). + if ($modelReflection->getName() === Model::class) { + return EloquentBuilder::class; + } + + $builder = $this->overridingBuilder($modelReflection, 'newModelQuery'); + + if ($builder !== null) { + return $builder; + } + + $method = $modelReflection->getNativeMethod('newEloquentBuilder'); + + // The override decides on its own, so its declared return is kept even when it is broader than the attribute. + if ($method->getDeclaringClass()->getName() !== Model::class) { + return $method->getVariants()[0]->getReturnType(); + } + + $builderClassName = $this->findUseEloquentBuilderClassName($modelReflection); + + // Like Model::newEloquentBuilder(), ignore a class that is not an Eloquent builder subclass. + if ($builderClassName !== null + && $this->reflectionProvider->hasClass($builderClassName) + && $this->reflectionProvider->getClass($builderClassName)->isSubclassOfClass($this->reflectionProvider->getClass(EloquentBuilder::class))) { + return $builderClassName; + } + + // A property default is not part of the model's declarations. + foreach (SourceFiles::forInstance($this->reflectionProvider, $modelReflection, Model::class) as $file) { + $this->declarationDependencyTracker->trackFileDependency($modelReflection, $file); + } + + $builderClassName = $modelReflection->getNativeReflection()->getDefaultProperties()['builder']; + + return is_string($builderClassName) && $this->reflectionProvider->hasClass($builderClassName) + ? $builderClassName + : EloquentBuilder::class; + } + + /** + * Return the builder a model's override of a query factory names: HasBuilder's @use type, else a native return + * naming a builder subclass. + * + * HasBuilder's override declares the builder its @use names, even when another trait's newEloquentBuilder() wins + * the conflict, as Nested Set's HasNode does. Its origin is checked by file first: reflecting an undocumented + * override resolves the Model stub's builder-of or model-query-of, which calls back here. For + * the same reason only another override's native return is read, so a PHPDoc-only narrowing is not seen, and an + * override declaring the base builder (such as one calling its parent) leaves the builder to the factory it + * builds on. + */ + private function overridingBuilder(ClassReflection $modelReflection, string $methodName): string|Type|null + { + $method = $modelReflection->getNativeReflection()->getMethod($methodName); + + if ($method->getDeclaringClass()->getName() === Model::class) { + return null; + } + + if ($method->getFileName() === $this->reflectionProvider->getClass(HasBuilder::class)->getFileName()) { + return $modelReflection->getNativeMethod($methodName)->getVariants()[0]->getReturnType(); + } + + $returnType = $method->getReturnType(); + + return $returnType instanceof ReflectionNamedType + && $this->reflectionProvider->hasClass($returnType->getName()) + && $this->reflectionProvider->getClass($returnType->getName())->isSubclassOfClass($this->reflectionProvider->getClass(EloquentBuilder::class)) + ? $returnType->getName() + : null; + } + + /** + * Mirror Model::resolveCustomBuilderClass(): the model, its traits, then each parent and its traits, nearest first. + * + * It instantiates the attribute, so the argument may also be named. + */ + private function findUseEloquentBuilderClassName(ClassReflection $modelReflection): ?string + { + foreach ([$modelReflection, ...$modelReflection->getParents()] as $classReflection) { + foreach ([$classReflection, ...$classReflection->getTraits()] as $sourceReflection) { + $attributes = $sourceReflection->getNativeReflection()->getAttributes(UseEloquentBuilder::class); + + if ($attributes === []) { + continue; + } + + $arguments = $attributes[0]->getArgumentsExpressions(); + + return $this->attributeClassNames->resolve($arguments[0] ?? $arguments['builderClass'] ?? null, $sourceReflection, $modelReflection); + } + } + + return null; + } + + /** + * Return the builder type the model creates, with the given model type bound to it. + * + * @param bool $modelQuery whether to return newModelQuery()'s builder instead of newQuery()'s + */ + public function determineBuilderClass(string $modelClassName, Type $modelType, bool $modelQuery = false): ?Type + { + // Unlike upstream, which crashes, a missing class is left to PHPStan's class.notFound report. + if (! $this->reflectionProvider->hasClass($modelClassName)) { + return null; + } + + $modelReflection = $this->reflectionProvider->getClass($modelClassName); + + if (! $modelReflection->is(Model::class)) { + return null; + } + + $builder = $modelQuery + ? ($this->modelQueryBuilders[$modelClassName] ??= $this->determineModelQueryBuilder($modelReflection)) + : ($this->builders[$modelClassName] ??= $this->determineBuilder($modelReflection)); + + return $this->getBuilderType(is_string($builder) ? new ObjectType($builder) : $builder, $modelType); + } + + /** + * Return an active template type from a class ancestor. + * + * @param class-string $ancestorClass + */ + public function templateType(ClassReflection $classReflection, string $ancestorClass, string $templateName): Type + { + $type = $classReflection + ->getAncestorWithClassName($ancestorClass) + ?->getActiveTemplateTypeMap() + ->getType($templateName); + + return $type ?? throw new LogicException(sprintf( + 'Template type [%s] is not available for [%s].', + $templateName, + $classReflection->getName(), + )); + } + + /** + * Determine whether a class owns a native or documented method, which a forwarded method would not replace. + */ + public function hasNativeOrDocumentedMethod(ClassReflection $classReflection, string $methodName): bool + { + if ($classReflection->hasNativeMethod($methodName)) { + return true; + } + + foreach (array_keys($classReflection->getMethodTags()) as $documentedMethod) { + if (strcasecmp($documentedMethod, $methodName) === 0) { + return true; + } + } + + return false; + } +} diff --git a/src/database/src/PHPStan/BuilderMacroResolver.php b/src/phpstan/src/Methods/BuilderMacroResolver.php similarity index 74% rename from src/database/src/PHPStan/BuilderMacroResolver.php rename to src/phpstan/src/Methods/BuilderMacroResolver.php index cf40df2f76..e30a1f2801 100644 --- a/src/database/src/PHPStan/BuilderMacroResolver.php +++ b/src/phpstan/src/Methods/BuilderMacroResolver.php @@ -2,9 +2,13 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods; +use Hypervel\PHPStan\Reflection\ForwardedFluentMethodReflection; +use Hypervel\PHPStan\Reflection\ForwardedMethodReflection; use PHPStan\Analyser\OutOfClassScope; +use PHPStan\DependencyInjection\AutowiredParameter; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ReflectionProvider; @@ -19,6 +23,7 @@ * Packages map each trait to an interface declaring its macros through the * eloquentBuilderMacros parameter. A generic interface receives the model type. */ +#[AutowiredService] class BuilderMacroResolver { private readonly OutOfClassScope $scope; @@ -30,6 +35,7 @@ class BuilderMacroResolver */ public function __construct( private readonly ReflectionProvider $reflectionProvider, + #[AutowiredParameter(ref: '%eloquentBuilderMacros%')] private readonly array $macros, ) { $this->scope = new OutOfClassScope; @@ -45,8 +51,11 @@ public function hasMacro(ClassReflection $modelClass, string $methodName): bool /** * Resolve a trait macro called on a builder for the given model type. + * + * The signature names the model by the signature model type, the receiver's own template type for it, so + * PHPStan binds the model only after replacing the receiver's static types. */ - public function resolve(ClassReflection $builderClass, Type $modelType, string $methodName): ?MethodReflection + public function resolve(ClassReflection $builderClass, Type $modelType, Type $signatureModelType, string $methodName): ?MethodReflection { $modelClasses = $modelType->getObjectClassReflections(); @@ -61,14 +70,15 @@ public function resolve(ClassReflection $builderClass, Type $modelType, string $ } $interfaceType = $macroInterface->isGeneric() - ? new GenericObjectType($macroInterface->getName(), [$modelType]) + ? new GenericObjectType($macroInterface->getName(), [$signatureModelType]) : new ObjectType($macroInterface->getName()); - $method = $interfaceType->getMethod($methodName, $this->scope); + // @phpstan-ignore phpstanApi.method (ObjectType::getMethod() resolves template types to their bounds.), phpstanApi.method (Same lookup.) + $method = $interfaceType->getUnresolvedMethodPrototype($methodName, $this->scope)->doNotResolveTemplateTypeMapToBounds()->getTransformedMethod(); // Fluent macros return the builder they run on. return $macroInterface->getNativeMethod($methodName)->getVariants()[0]->getReturnType() instanceof StaticType ? new ForwardedFluentMethodReflection($builderClass, $method) - : $method; + : new ForwardedMethodReflection($builderClass, $method); } /** diff --git a/src/phpstan/src/Methods/DynamicRelationMethodExtension.php b/src/phpstan/src/Methods/DynamicRelationMethodExtension.php new file mode 100644 index 0000000000..1e8178ea9d --- /dev/null +++ b/src/phpstan/src/Methods/DynamicRelationMethodExtension.php @@ -0,0 +1,166 @@ + */ + private array $methods = []; + + /** + * Create a dynamic relation method extension. + */ + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly ModelHelper $modelHelper, + private readonly BuilderHelper $builderHelper, + private readonly ClosureTypeFactory $closureTypeFactory, + ) { + } + + /** + * Determine whether the class's __call() handles the method as a relation. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + return $this->resolveMethod($classReflection, $methodName) !== null; + } + + /** + * Return the relation method. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->resolveMethod($classReflection, $methodName) + ?? throw new LogicException(sprintf( + 'Dynamic relation method [%s::%s] was not resolved.', + $classReflection->getName(), + $methodName, + )); + } + + /** + * Resolve and cache a relation method that __call() handles. + */ + private function resolveMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + $isModel = $classReflection->is(Model::class); + + if (! $isModel && ! $classReflection->is(PendingHasThroughRelationship::class)) { + return null; + } + + // Keep the exact spelling: resolver names and the prefixes are case-sensitive. + $cacheKey = $classReflection->getCacheKey() . ':' . $methodName; + + if (array_key_exists($cacheKey, $this->methods)) { + $cachedMethod = $this->methods[$cacheKey]; + + return $cachedMethod === false ? null : $cachedMethod; + } + + $method = null; + + if ($isModel) { + $method = $this->resolveModelMethod($classReflection, $methodName); + } elseif (str_starts_with($methodName, 'has') && ! $this->builderHelper->hasNativeOrDocumentedMethod($classReflection, $methodName)) { + // PendingHasThroughRelationship::__call() turns any has{Relation}() into has('relation'). + $method = $this->selectorMethod($classReflection, $methodName, 'has', lcfirst(substr($methodName, 3)), false); + } + + $this->methods[$cacheKey] = $method ?? false; + + return $method; + } + + /** + * Resolve a relation that Model::__call() handles before forwarding to the builder: a registered resolver, + * then through{Relation}() when the model has the relation's method. + * + * Model::__callStatic() calls these on a new model, so they are static too. + */ + private function resolveModelMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + if ($this->builderHelper->hasNativeOrDocumentedMethod($classReflection, $methodName)) { + return null; + } + + try { + $resolver = $this->modelHelper->getRelationResolver($classReflection, $methodName); + } catch (ReflectionException) { + $resolver = null; + } + + // The resolver is never run; the model call passes the model to it, so it takes no arguments. + if ($resolver !== null) { + return new DynamicRelationMethodReflection( + $classReflection, + $methodName, + $this->closureTypeFactory->fromClosureObject($resolver)->getReturnType(), + true, + ); + } + + // The prefix is case-sensitive, as Str::startsWith() is. + if (! str_starts_with($methodName, 'through')) { + return null; + } + + $relationName = lcfirst(substr($methodName, 7)); + + return $classReflection->hasNativeMethod($relationName) + ? $this->selectorMethod($classReflection, $methodName, 'through', $relationName, true) + : null; + } + + /** + * Describe a call that __call() turns into the selector method with a relation name, typed by what the + * selector method declares for a string. + */ + private function selectorMethod( + ClassReflection $classReflection, + string $methodName, + string $selectorMethod, + string $relationName, + bool $isStatic, + ): MethodReflection { + // Read the declaration as the class declares it: PHPStan binds the receiver's template types after replacing + // its $this and static types, so a receiver's late-static model is not mistaken for the receiver. + $variants = $this->reflectionProvider->getClass($classReflection->getName())->getNativeMethod($selectorMethod)->getVariants(); + + return new DynamicRelationMethodReflection( + $classReflection, + $methodName, + ParametersAcceptorSelector::selectFromTypes([new ConstantStringType($relationName)], $variants, false)->getReturnType(), + $isStatic, + ); + } +} diff --git a/src/phpstan/src/Methods/FacadesMethodsExtension.php b/src/phpstan/src/Methods/FacadesMethodsExtension.php new file mode 100644 index 0000000000..ffb9fbb966 --- /dev/null +++ b/src/phpstan/src/Methods/FacadesMethodsExtension.php @@ -0,0 +1,136 @@ + */ + private array $cache = []; + + /** @var array */ + private array $facadeRoots = []; + + /** + * Create a new facade methods extension. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Determine whether the facade's booted root declares the method its PHPDoc omits. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + if (! $classReflection->is(Facade::class)) { + return false; + } + + $key = $classReflection->getName() . '-' . $methodName; + + if (isset($this->cache[$key])) { + return $this->cache[$key] !== false; + } + + $result = RecursionGuard::run($key, function () use ($classReflection, $methodName, $key) { + if (ReflectionHelper::hasMethodTag($classReflection, $methodName)) { + return false; + } + + $facadeClass = $classReflection->getName(); + + $this->runtimeInputs->track($classReflection); + // The facade's own accessor selects its root. + $this->runtimeInputs->trackSources($classReflection, $facadeClass, Facade::class); + + $concrete = $this->getFacadeRoot($facadeClass); + + if ($concrete !== null) { + $concreteClass = $concrete::class; + + if ($this->reflectionProvider->hasClass($concreteClass)) { + $this->runtimeInputs->trackClass($classReflection, $concreteClass); + + $concreteReflection = $this->reflectionProvider->getClass($concreteClass); + + // Use hasNativeMethod() instead of hasMethod() to avoid + // re-entering registered MethodsClassReflectionExtensions + // (including this one), which would cause infinite recursion. + // The facade calls the root from outside, so only a public method is reachable. + if ( + $concreteReflection->hasNativeMethod($methodName) + && $concreteReflection->getNativeMethod($methodName)->isPublic() + ) { + $this->cache[$key] = new StaticMethodReflection( + $concreteReflection->getNativeMethod($methodName), + ); + + return true; + } + } + } + + return false; + }); + + if ($result === false) { + $this->cache[$key] = false; + } + + return $result ?? false; + } + + /** + * Get the booted root object behind the facade. + * + * @param class-string $facadeClass + */ + private function getFacadeRoot(string $facadeClass): ?object + { + if (array_key_exists($facadeClass, $this->facadeRoots)) { + return $this->facadeRoots[$facadeClass]; + } + + $concrete = null; + + try { + $concrete = $facadeClass::getFacadeRoot(); + } catch (Throwable) { + } + + return $this->facadeRoots[$facadeClass] = is_object($concrete) ? $concrete : null; + } + + /** + * Get the root's method as a static facade method. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + // hasMethod() returns true only after caching the method. + /** @var MethodReflection $method */ + $method = $this->cache[$classReflection->getName() . '-' . $methodName]; + + return $method; + } +} diff --git a/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php b/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php new file mode 100644 index 0000000000..644fff49d4 --- /dev/null +++ b/src/phpstan/src/Methods/ForwardedBuilderMethodExtension.php @@ -0,0 +1,316 @@ + */ + private const array RELATION_NON_DECORATED_METHODS = [ + 'clonewithout', + 'clonewithoutbindings', + ]; + + /** @var list */ + private const array RELATION_DOCUMENTED_FLUENT_METHODS = [ + 'wherecan', + 'withcan', + ]; + + /** @var array */ + private array $methods = []; + + private readonly OutOfClassScope $scope; + + /** + * Create a forwarded builder method extension. + */ + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly ModelScopeMethodResolver $scopeMethods, + private readonly BuilderMacroResolver $builderMacros, + private readonly BuilderDependencies $builderDependencies, + private readonly BuilderHelper $builderHelper, + private readonly MacroMethodsClassReflectionExtension $macroMethods, + ) { + $this->scope = new OutOfClassScope; + } + + /** + * Determine whether the class exposes the forwarded builder method. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + return $this->resolveMethod($classReflection, $methodName) !== null; + } + + /** + * Return the forwarded builder method. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->resolveMethod($classReflection, $methodName) + ?? throw new LogicException(sprintf( + 'Forwarded builder method [%s::%s] was not resolved.', + $classReflection->getName(), + $methodName, + )); + } + + /** + * Resolve and cache a forwarded builder method. + */ + private function resolveMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + $isEloquentHost = $classReflection->is(EloquentBuilder::class); + $isRelationHost = ! $isEloquentHost && $classReflection->is(Relation::class); + + if (! $isEloquentHost && ! $isRelationHost) { + return null; + } + + // Keep the exact spelling: builder macro names are case-sensitive. + $cacheKey = $classReflection->getCacheKey() . ':' . $methodName; + + if (array_key_exists($cacheKey, $this->methods)) { + $cachedMethod = $this->methods[$cacheKey]; + + return $cachedMethod === false ? null : $cachedMethod; + } + + $method = null; + + if (! $this->builderHelper->hasNativeOrDocumentedMethod($classReflection, $methodName)) { + if ($isEloquentHost) { + $this->builderDependencies->track($classReflection, $classReflection); + $method = $this->resolveEloquentBuilderMethod( + $classReflection, + $methodName, + $this->signatureModelType($classReflection, EloquentBuilder::class, 'TModel'), + ); + } else { + $method = $this->resolveRelationMethod($classReflection, $methodName); + } + } + + $this->methods[$cacheKey] = $method ?? false; + + return $method; + } + + /** + * Resolve a query method forwarded by an Eloquent builder, naming the model in its signature by the signature model type. + */ + private function resolveEloquentBuilderMethod( + ClassReflection $classReflection, + string $methodName, + Type $signatureModelType, + ): ?MethodReflection { + // Builder::__call() and __callStatic() register a local or global macro before any other dispatch. + if ($methodName === 'macro') { + return new MacroRegistrationMethodReflection( + $classReflection, + $this->reflectionProvider->getClass(QueryBuilder::class)->getNativeMethod($methodName), + ); + } + + $modelType = $this->builderHelper->templateType($classReflection, EloquentBuilder::class, 'TModel'); + + // Local macros run before named scopes in Builder::__call(). + $macro = $this->builderMacros->resolve($classReflection, $modelType, $signatureModelType, $methodName); + + if ($macro !== null) { + return $macro; + } + + // Global macros, then named scopes, run next. Their own extensions describe them, and relations reach + // global macros through their builder mixin. + if ($this->macroMethods->hasMethod($classReflection, $methodName) + || $this->hasNamedScope($modelType, $methodName)) { + return null; + } + + [$storedType, $baseType] = $this->builderHelper->queryTypes($classReflection); + $isPassthru = $this->builderHelper->isPassthru($classReflection, $methodName); + // Passthru runs on the raw query toBase() returns, whose rows stay as declared. + $queryType = $isPassthru ? $baseType : $this->builderHelper->bindQueryRows($storedType, $signatureModelType); + + // A query that never exists runs no method. + if ($queryType instanceof NeverType) { + return null; + } + + $queryClasses = $queryType->getObjectClassReflections(); + + // Query\Builder::__call() runs a macro, else handles a where clause the query does not declare. + // ModelPropertyColumnRule checks a dynamic where's columns: a model-dependent answer here would make + // PHPStan report a generic subclass's call as private. + if (count($queryClasses) !== 1 + || ! ($queryClasses[0]->hasNativeMethod($methodName) || $this->macroMethods->hasMethod($queryClasses[0], $methodName))) { + return str_starts_with($methodName, 'where') + ? new ForwardedFluentMethodReflection( + $classReflection, + new EloquentBuilderMethodReflection($methodName, $classReflection, [new DynamicWhereParameterReflection], isVariadic: true), + ) + : null; + } + + $method = $this->unresolvedMethod($queryType, $methodName); + + if (! $method->isPublic()) { + return null; + } + + if ($isPassthru) { + return new ForwardedMethodReflection($classReflection, $method); + } + + // __callStatic() registers a mixin's methods as global macros, so the query's mixin() is static here too. + if ($methodName === 'mixin') { + return new MacroRegistrationMethodReflection($classReflection, $method); + } + + // Eloquent discards ordinary forwarded results, regardless of their declared type. + return new ForwardedFluentMethodReflection($classReflection, $method); + } + + /** + * Resolve a builder method forwarded by a relation. + */ + private function resolveRelationMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + $normalizedMethodName = strtolower($methodName); + $relatedType = $this->builderHelper->templateType($classReflection, Relation::class, 'TRelatedModel'); + $builderClasses = $this->eloquentBuilderType($relatedType, $relatedType)->getObjectClassReflections(); + + if (count($builderClasses) !== 1) { + return null; + } + + $eloquentBuilder = $builderClasses[0]; + + // Relation callers depend on the related model, not on its builder. + foreach ($relatedType->getObjectClassReflections() as $relatedClass) { + $this->builderDependencies->track($relatedClass, $eloquentBuilder); + } + + $signatureModelType = $this->signatureModelType($classReflection, Relation::class, 'TRelatedModel'); + $builderType = $this->eloquentBuilderType($relatedType, $signatureModelType); + + if ($eloquentBuilder->hasNativeMethod($methodName)) { + $method = $this->unresolvedMethod($builderType, $methodName); + + if (! $method->isPublic()) { + return null; + } + + // Without scopes, applyScopes() returns the same builder, which Relation::__call() turns into the relation. + if ($normalizedMethodName === 'applyscopes') { + return new ForwardedFluentMethodReflection($classReflection, $method, mayReturnForwardedResult: true); + } + + if (in_array($normalizedMethodName, self::RELATION_NON_DECORATED_METHODS, strict: true) + || ! $this->returnsStatic($eloquentBuilder->getNativeMethod($methodName))) { + return new ForwardedMethodReflection($classReflection, $method); + } + + return new ForwardedFluentMethodReflection($classReflection, $method); + } + + $method = $this->resolveEloquentBuilderMethod($eloquentBuilder, $methodName, $signatureModelType); + + if ($method !== null) { + return $method instanceof ForwardedFluentMethodReflection + ? new ForwardedFluentMethodReflection($classReflection, $method) + : new ForwardedMethodReflection($classReflection, $method); + } + + if ($this->hasNamedScope($relatedType, $methodName) + || ! in_array($normalizedMethodName, self::RELATION_DOCUMENTED_FLUENT_METHODS, strict: true)) { + return null; + } + + return new ForwardedFluentMethodReflection($classReflection, $this->unresolvedMethod($builderType, $methodName)); + } + + /** + * Return a class's ancestor template type as the class's own template types declare it. + * + * Forwarded signatures name the receiver's model this way. PHPStan binds the receiver's template types + * after replacing its static types, so a late-static model is not mistaken for the receiver. + * + * @param class-string $ancestorClass + */ + private function signatureModelType(ClassReflection $classReflection, string $ancestorClass, string $templateName): Type + { + return $this->builderHelper->templateType($this->reflectionProvider->getClass($classReflection->getName()), $ancestorClass, $templateName); + } + + /** + * Return a type's method, keeping the template types it is instantiated with instead of their bounds. + */ + private function unresolvedMethod(Type $type, string $methodName): ExtendedMethodReflection + { + // @phpstan-ignore phpstanApi.method (ObjectType::getMethod() resolves template types to their bounds.), phpstanApi.method (Same lookup.) + return $type->getUnresolvedMethodPrototype($methodName, $this->scope)->doNotResolveTemplateTypeMapToBounds()->getTransformedMethod(); + } + + /** + * Resolve the related model's builder holding the given model type, or the base builder for several or unknown models. + */ + private function eloquentBuilderType(Type $relatedType, Type $modelType): Type + { + $modelClassNames = $relatedType->getObjectClassNames(); + + return (count($modelClassNames) === 1 ? $this->builderHelper->determineBuilderClass($modelClassNames[0], $modelType) : null) + ?? new GenericObjectType(EloquentBuilder::class, [$modelType]); + } + + /** + * Determine whether a named scope owns dispatch before query forwarding. + */ + private function hasNamedScope(Type $modelType, string $methodName): bool + { + return $this->scopeMethods->resolveForType($modelType, $methodName) !== null; + } + + /** + * Determine whether every method variant returns static. + */ + private function returnsStatic(MethodReflection $method): bool + { + foreach ($method->getVariants() as $variant) { + if (! $variant->getReturnType() instanceof StaticType) { + return false; + } + } + + return true; + } +} diff --git a/src/phpstan/src/Methods/ForwardedModelMethodExtension.php b/src/phpstan/src/Methods/ForwardedModelMethodExtension.php new file mode 100644 index 0000000000..5ecd8eb5e9 --- /dev/null +++ b/src/phpstan/src/Methods/ForwardedModelMethodExtension.php @@ -0,0 +1,154 @@ + */ + private array $methods = []; + + private readonly OutOfClassScope $scope; + + /** + * Create a forwarded model method extension. + */ + public function __construct( + private readonly ModelScopeMethodResolver $scopeMethods, + private readonly BuilderMacroResolver $builderMacros, + private readonly BuilderDependencies $builderDependencies, + private readonly BuilderHelper $builderHelper, + private readonly MacroMethodsClassReflectionExtension $macroMethods, + private readonly DynamicRelationMethodExtension $dynamicRelations, + ) { + $this->scope = new OutOfClassScope; + } + + /** + * Determine whether the model exposes the forwarded builder method. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + return $this->resolveMethod($classReflection, $methodName) !== null; + } + + /** + * Return the forwarded builder method. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->resolveMethod($classReflection, $methodName) + ?? throw new LogicException(sprintf( + 'Forwarded model method [%s::%s] was not resolved.', + $classReflection->getName(), + $methodName, + )); + } + + /** + * Resolve and cache a builder method forwarded through a model. + */ + private function resolveMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + if (! $classReflection->is(Model::class)) { + return null; + } + + // Keep the exact spelling: builder macro names are case-sensitive. + $cacheKey = $classReflection->getCacheKey() . ':' . $methodName; + + if (array_key_exists($cacheKey, $this->methods)) { + $cachedMethod = $this->methods[$cacheKey]; + + return $cachedMethod === false ? null : $cachedMethod; + } + + $method = null; + + // Unlike upstream, these are static too: __callStatic() calls them on a new model from the model's scope. + if (in_array($methodName, self::MODEL_CALL_FORWARDED_METHODS, true) && $classReflection->hasNativeMethod($methodName)) { + $method = new ForwardedModelMethodReflection($classReflection, $classReflection->getNativeMethod($methodName)); + } elseif (! $this->builderHelper->hasNativeOrDocumentedMethod($classReflection, $methodName) + // Model::__call() runs dynamic relations and through{Relation}() before forwarding to the builder. + && ! $this->dynamicRelations->hasMethod($classReflection, $methodName)) { + // Keep static, so PHPStan binds it to the called model. + $builderType = $this->builderHelper->determineBuilderClass($classReflection->getName(), new StaticType($classReflection)); + + foreach ($builderType->getObjectClassReflections() as $builderClass) { + $this->builderDependencies->track($classReflection, $builderClass); + } + + $scopeMethod = $this->scopeMethods->resolve($classReflection, $methodName); + + // Native builder methods and local and global macros run before named scopes. + if (($scopeMethod === null + || $this->builderMethodTakesPrecedence($builderType, $methodName) + || $this->builderMacros->hasMacro($classReflection, $methodName)) + && $builderType->hasMethod($methodName)->yes()) { + $builderMethod = $builderType->getMethod($methodName, $this->scope); + + // Model::__call() cannot reach a protected builder method: the builder's own __call() runs and throws. + if ($builderMethod->isPublic()) { + // Model::__call() makes an instance call, so the builder's mixin() reaches the query's and returns it. + $method = new ForwardedModelMethodReflection( + $classReflection, + $builderMethod, + $methodName === 'mixin' && $builderMethod->getPrototype() instanceof MacroRegistrationMethodReflection + ? $builderType + : null, + ); + } + } + } + + $this->methods[$cacheKey] = $method ?? false; + + return $method; + } + + /** + * Determine whether an active builder owns a public native method or global macro. + * + * A protected builder method is unreachable from Model::__call(), so the builder's __call() runs a scope of that name. + */ + private function builderMethodTakesPrecedence(Type $builderType, string $methodName): bool + { + foreach ($builderType->getObjectClassReflections() as $builderClass) { + if (($builderClass->hasNativeMethod($methodName) && $builderClass->getNativeMethod($methodName)->isPublic()) + || $this->macroMethods->hasMethod($builderClass, $methodName)) { + return true; + } + } + + return false; + } +} diff --git a/src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php b/src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php new file mode 100644 index 0000000000..bb6f464623 --- /dev/null +++ b/src/phpstan/src/Methods/HigherOrderCollectionProxyExtension.php @@ -0,0 +1,38 @@ +higherOrderCollectionProxyHelper->hasPropertyOrMethod($classReflection, $methodName, 'method'); + } + + /** + * Get the method, returning what the proxied collection method returns. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->higherOrderCollectionProxyHelper->getMethod($classReflection, $methodName); + } +} diff --git a/src/phpstan/src/Methods/HigherOrderTapProxyExtension.php b/src/phpstan/src/Methods/HigherOrderTapProxyExtension.php new file mode 100644 index 0000000000..ed614ed1dd --- /dev/null +++ b/src/phpstan/src/Methods/HigherOrderTapProxyExtension.php @@ -0,0 +1,61 @@ +getTargetType($classReflection); + + // The proxy calls the method from outside the tapped object's class. + return $targetType !== null + && $targetType->hasMethod($methodName)->yes() + && $targetType->getMethod($methodName, new OutOfClassScope)->isPublic(); + } + + /** + * Get the tapped object's method, returning the tapped object. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + // hasMethod() accepts only proxies with a tapped object. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + + return new ProxiedMethodReflection( + $classReflection, + $targetType->getMethod($methodName, new OutOfClassScope), + static fn (): Type => $targetType, + ); + } + + /** + * Get the tapped object's type. + */ + private function getTargetType(ClassReflection $classReflection): ?Type + { + if ($classReflection->getName() !== HigherOrderTapProxy::class) { + return null; + } + + $targetType = $classReflection->getActiveTemplateTypeMap()->getType('TTarget'); + + return $targetType !== null && $targetType->getObjectClassReflections() !== [] ? $targetType : null; + } +} diff --git a/src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php b/src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php new file mode 100644 index 0000000000..8abf5d8e49 --- /dev/null +++ b/src/phpstan/src/Methods/HigherOrderWhenProxyExtension.php @@ -0,0 +1,38 @@ +higherOrderWhenProxyHelper->hasPropertyOrMethod($classReflection, $methodName, 'method'); + } + + /** + * Get the method, returning what the proxy returns for it. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->higherOrderWhenProxyHelper->getMethod($classReflection, $methodName); + } +} diff --git a/src/phpstan/src/Methods/Macro.php b/src/phpstan/src/Methods/Macro.php new file mode 100644 index 0000000000..4a0c85dd5c --- /dev/null +++ b/src/phpstan/src/Methods/Macro.php @@ -0,0 +1,176 @@ + ValidationException::class, + 'validateWithBag' => ValidationException::class, + ]; + + /** + * Create a macro method reflection. + */ + public function __construct(private ClassReflection $classReflection, private string $methodName, private ClosureType $closureType) + { + } + + /** + * Return the class the macro is registered on. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->classReflection; + } + + /** + * Determine whether the macro is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the macro is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Determine whether the macro is final. + */ + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the macro is internal. + */ + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the macro is called statically. + */ + public function isStatic(): bool + { + return $this->isStatic; + } + + /** + * Set the is static value. + */ + public function setIsStatic(bool $isStatic): void + { + $this->isStatic = $isStatic; + } + + /** + * Return the macro docblock. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Return the macro name. + */ + public function getName(): string + { + return $this->methodName; + } + + /** + * Determine whether the macro is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Return the macro prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return the macro's variant, built from its callable. + */ + public function getVariants(): array + { + // Hypervel: Macroable passes the macro its argument values, so a by-reference + // parameter never receives the caller's variable. + $parameters = ProxiedParameterReflection::fromTargetParameters($this->closureType->getParameters()); + + return [ + new FunctionVariant(TemplateTypeMap::createEmpty(), null, $parameters, $this->closureType->isVariadic(), $this->closureType->getReturnType()), + ]; + } + + /** + * Return the macro's deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return null; + } + + /** + * Return the exception type a known framework macro throws. + */ + public function getThrowType(): ?Type + { + // Hypervel: the map describes Request's validation macros; other classes may register macros with the same names. + if ($this->classReflection->is(Request::class) && array_key_exists($this->methodName, $this->methodThrowTypeMap)) { + return new ObjectType($this->methodThrowTypeMap[$this->methodName]); + } + + return null; + } + + /** + * Determine whether the macro has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } +} diff --git a/src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php new file mode 100644 index 0000000000..5dc9248b24 --- /dev/null +++ b/src/phpstan/src/Methods/MacroMethodsClassReflectionExtension.php @@ -0,0 +1,397 @@ + */ + private array $methods = []; + + /** @var array> */ + private array $traitCache = []; + + /** @var array */ + private array $macroSources = []; + + /** + * Create a macro methods extension. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClosureTypeFactory $closureTypeFactory, + private WithHeadExtension $withHeadExtension, + private AuthGuardHelper $authGuardHelper, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Determine whether a macro registered for the class types the method. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + if ($this->findMacro($classReflection, $methodName) === null) { + return false; + } + + // Hypervel: Head's withHead() signature and an @method tag on the class or the macro's host type the + // macro more precisely than its callable, whose declared signature is all the analysis reads, such as + // Request::validate()'s array and the Eloquent builder's $this returns. MacroThrowTypeExtension adds the + // macro's exceptions. A mixin does not count, because the macro runs before a call reaches the mixin. + return ! $this->withHeadExtension->hasMethod($classReflection, $methodName) + && ! $this->hasMethodTag($classReflection, $methodName); + } + + /** + * Get the macro found for the class. + */ + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + // hasMethod() returns true only for a found macro. + /** @var MethodReflection $method */ + $method = $this->findMacro($classReflection, $methodName); + + return $method; + } + + /** + * Get the exception type of the macro registered for the class's method. + */ + public function getThrowType(ClassReflection $classReflection, string $methodName): ?Type + { + return $this->findMacro($classReflection, $methodName)?->getThrowType(); + } + + /** + * Find the macro registered for the class's method. + */ + private function findMacro(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + $cacheKey = $classReflection->getName() . '-' . $methodName; + + if (array_key_exists($cacheKey, $this->methods)) { + return $this->methods[$cacheKey] === false ? null : $this->methods[$cacheKey]; + } + + $manager = $this->getGuardForwarder($classReflection); + $methodReflection = $manager !== null + ? $this->findGuardMacro($classReflection, $manager, $methodName) + : $this->findHostMacro($classReflection, $methodName); + + $this->methods[$cacheKey] = $methodReflection ?? false; + + return $methodReflection; + } + + /** + * Find the macro registered on the classes whose macros apply to the given class. + * + * @throws ReflectionException + * @throws ShouldNotHappenException + * @throws MissingMethodFromReflectionException + */ + private function findHostMacro(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + [$classNames, $macroTraitProperty] = $this->getMacroSources($classReflection); + + $methodReflection = null; + + if ($classNames !== [] && $macroTraitProperty) { + foreach ($classNames as $className) { + $macroClassReflection = $this->reflectionProvider->getClass($className); + + if (! $macroClassReflection->getNativeReflection()->hasProperty($macroTraitProperty)) { + continue; + } + + $refProperty = $macroClassReflection->getNativeReflection()->getProperty($macroTraitProperty); + + // Hypervel: macros are registered at runtime, so the registry of an owner the booted application + // never loaded, such as a class that only analyzed files declare, is unavailable. + if (! class_exists($refProperty->getDeclaringClass()->getName(), false)) { + continue; + } + + $macros = $refProperty->getValue(); + + if (! array_key_exists($methodName, $macros)) { + continue; + } + + $macroDefinition = $macros[$methodName]; + + // Hypervel: Macroable also accepts an invokable object, which it calls directly. + if (is_object($macroDefinition) && ! $macroDefinition instanceof Closure) { + $macroDefinition = [$macroDefinition, '__invoke']; + } + + // Hypervel: Macroable dispatches every macro through both __call() and __callStatic() with its + // argument values, so each form is callable both ways (StaticMethodReflection for methods). + if (is_string($macroDefinition)) { + if (str_contains($macroDefinition, '::')) { + $macroDefinition = explode('::', $macroDefinition, 2); + $macroClassName = $macroDefinition[0]; + + if (! $this->reflectionProvider->hasClass($macroClassName) || ! $this->reflectionProvider->getClass($macroClassName)->hasNativeMethod($macroDefinition[1])) { + throw new ShouldNotHappenException('Class ' . $macroClassName . ' does not exist'); + } + + $methodReflection = new StaticMethodReflection( + $this->reflectionProvider->getClass($macroClassName)->getNativeMethod($macroDefinition[1]), + ); + } elseif (is_callable($macroDefinition)) { + $methodReflection = new Macro( + $macroClassReflection, + $methodName, + $this->closureTypeFactory->fromClosureObject(Closure::fromCallable($macroDefinition)), + ); + + $methodReflection->setIsStatic(true); + } else { + throw new ShouldNotHappenException('Function ' . $macroDefinition . ' does not exist'); + } + } elseif (is_array($macroDefinition)) { + if (is_string($macroDefinition[0])) { + $macroClassName = $macroDefinition[0]; + } else { + $macroClassName = $macroDefinition[0]::class; + } + + if (! $this->reflectionProvider->hasClass($macroClassName) || ! $this->reflectionProvider->getClass($macroClassName)->hasNativeMethod($macroDefinition[1])) { + throw new ShouldNotHappenException('Class ' . $macroClassName . ' does not exist'); + } + + $methodReflection = new StaticMethodReflection( + $this->reflectionProvider->getClass($macroClassName)->getNativeMethod($macroDefinition[1]), + ); + } else { + $methodReflection = new Macro( + $macroClassReflection, + $methodName, + $this->closureTypeFactory->fromClosureObject($macroDefinition), + ); + + $methodReflection->setIsStatic(true); + } + + break; + } + } + + return $methodReflection; + } + + /** + * Get the auth manager receiving the class's calls, when AuthManager's own __call() forwards them to the current guard. + */ + private function getGuardForwarder(ClassReflection $classReflection): ?ClassReflection + { + // A facade, such as Auth or a custom one over the auth binding, forwards to its booted root, which getMacroSources() resolves. + if ($classReflection->is(Facade::class)) { + [$classNames] = $this->getMacroSources($classReflection); + + if ($classNames === []) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($classNames[0]); + } + + // A subclass's Macroable or custom __call() keeps its own API. + return $classReflection->is(AuthManager::class) + && $classReflection->getNativeMethod('__call')->getDeclaringClass()->getName() === AuthManager::class + ? $classReflection + : null; + } + + /** + * Find a guard macro that the class forwards to the current guard. + * + * Hypervel: replaces upstream's fixed SessionGuard and RequestGuard hosts, which claimed a macro the current + * guard may lack. shouldUse() can make any configured guard the current one, so each must have a public + * method of that name, natively or as a macro. At least one must supply it as a macro; the guards' other + * methods are not this extension's. + */ + private function findGuardMacro(ClassReflection $classReflection, ClassReflection $manager, string $methodName): ?MethodReflection + { + $guardType = $this->authGuardHelper->getDefaultGuardType($classReflection, [$manager->getName()]); + + if ($guardType === null) { + return null; + } + + // The guard classes supply this class's forwarded methods, so its callers depend on them. + foreach ($guardType->getObjectClassNames() as $guardClass) { + $this->runtimeInputs->trackClass($classReflection, $guardClass); + } + + if (! $guardType->hasMethod($methodName)->yes()) { + return null; + } + + $suppliesMacro = array_any( + $guardType->getObjectClassReflections(), + fn (ClassReflection $guard): bool => $this->findMacro($guard, $methodName) !== null, + ); + + if (! $suppliesMacro) { + return null; + } + + // Each guard's own lookup keeps its signature, including a native method or an @method tag. + $method = $guardType->getMethod($methodName, new OutOfClassScope); + + // AuthManager::__call() runs outside the guard, so it reaches only public methods. + if (! $method->isPublic()) { + return null; + } + + // The facade's call is static, while AuthManager has no __callStatic(), so its own calls are instance calls. + return $classReflection->is(Facade::class) + ? new StaticMethodReflection($method) + : new ProxiedMethodReflection($classReflection, $method, static fn (Type $returnType): Type => $returnType); + } + + /** + * Determine whether the class or the macro's host declares an @method tag for the method. + */ + private function hasMethodTag(ClassReflection $classReflection, string $methodName): bool + { + [$classNames] = $this->getMacroSources($classReflection); + + foreach ([$classReflection->getName(), ...$classNames] as $className) { + foreach ($this->reflectionProvider->getClass($className)->getAncestors() as $ancestor) { + if (array_key_exists($methodName, $ancestor->getMethodTags())) { + return true; + } + } + } + + return false; + } + + /** + * Determine which classes' macro definitions apply to the given class. + * + * This only depends on the class itself, so it is memoized — the trait + * scans and facade root lookups involved are too expensive to repeat + * for every method lookup. + * + * Only a macro registry or a facade root comes from the boot, so only those + * classes depend on it; any other class has no macros whatever the boot + * registers. A class's dependency holds for the whole process, so declaring + * it here, once, covers every lookup. + * + * @return array{class-string[], null|string} + */ + private function getMacroSources(ClassReflection $classReflection): array + { + $cacheKey = $classReflection->getName(); + + if (array_key_exists($cacheKey, $this->macroSources)) { + return $this->macroSources[$cacheKey]; + } + + /** @var class-string[] $classNames */ + $classNames = []; + $macroTraitProperty = null; + + // REMOVED: upstream's contract branch, which gives a Hypervel\Contracts interface the macros of its bound + // implementation, as ContractsMethodsExtension gives it the implementation's methods. Another implementation + // need not have them; explicit resolution such as app() or response() is typed from the binding instead. + if ( + $this->hasIndirectTraitUse($classReflection, Macroable::class) + || $classReflection->is(Builder::class) + || $classReflection->is(QueryBuilder::class) + ) { + $this->runtimeInputs->track($classReflection); + + // REMOVED: upstream's extra base Builder registry for subclasses. The subclass's reflected $macros + // already is the inherited registry, unless the subclass redeclares it to keep its own. + $classNames = [$classReflection->getName()]; + $macroTraitProperty = 'macros'; + } elseif ($classReflection->is(Facade::class)) { + // REMOVED: upstream's fixed hosts for the Auth and Cache facades. findGuardMacro() handles Auth. The + // Cache facade's root, CacheManager, is not Macroable, and the facade's mixins already reach the + // store Repository's macros. + $this->runtimeInputs->track($classReflection); + + $facadeClass = $classReflection->getName(); + + // The facade's own accessor selects its root. + $this->runtimeInputs->trackSources($classReflection, $facadeClass, Facade::class); + + $concrete = null; + + try { + $concrete = $facadeClass::getFacadeRoot(); + } catch (Throwable) { + } + + if (is_object($concrete)) { + $this->runtimeInputs->trackClass($classReflection, $concrete::class); + + $classNames = [$concrete::class]; + $macroTraitProperty = 'macros'; + } + } + + return $this->macroSources[$cacheKey] = [$classNames, $macroTraitProperty]; + } + + /** + * Determine whether the class or one of its ancestors uses the trait. + */ + private function hasIndirectTraitUse(ClassReflection $class, string $traitName): bool + { + $className = $class->getName(); + + if (array_key_exists($className, $this->traitCache) && array_key_exists($traitName, $this->traitCache[$className])) { + return $this->traitCache[$className][$traitName]; + } + + $this->traitCache[$className][$traitName] = in_array($traitName, array_keys($class->getTraits(true)), true); + + return $this->traitCache[$className][$traitName]; + } +} diff --git a/src/phpstan/src/Methods/MacroRegistrationExtension.php b/src/phpstan/src/Methods/MacroRegistrationExtension.php new file mode 100644 index 0000000000..485a9b08bf --- /dev/null +++ b/src/phpstan/src/Methods/MacroRegistrationExtension.php @@ -0,0 +1,72 @@ +getName() === 'mixin' + && $methodReflection->getPrototype() instanceof MacroRegistrationMethodReflection; + } + + /** + * Return the builder an instance mixin() returns. + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + return $scope->getType($methodCall->var); + } + + /** + * Determine whether the call registers a global macro. + */ + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $methodReflection->getName() === 'macro' + && $methodReflection->getPrototype() instanceof MacroRegistrationMethodReflection; + } + + /** + * Bind a global macro to the builder it is registered on. + */ + public function getClosureThisTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + ParameterReflection $parameter, + Scope $scope, + ): Type { + return new ObjectType($methodReflection->getDeclaringClass()->getName()); + } +} diff --git a/src/phpstan/src/Methods/MacroThrowTypeExtension.php b/src/phpstan/src/Methods/MacroThrowTypeExtension.php new file mode 100644 index 0000000000..058cd6775e --- /dev/null +++ b/src/phpstan/src/Methods/MacroThrowTypeExtension.php @@ -0,0 +1,125 @@ +isTaggedMacroWithThrowType($methodReflection); + } + + /** + * Get the exception of the macro registered on the call's receiver. + */ + public function getThrowTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + return $this->resolveThrowType( + $methodReflection, + $scope->getType($methodCall->var)->getObjectClassReflections(), + ); + } + + /** + * Determine whether the static method is a tag-typed macro with a known exception. + */ + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $this->isTaggedMacroWithThrowType($methodReflection); + } + + /** + * Get the exception of the macro registered on the called class, or on a facade's root. + */ + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (! $methodCall->class instanceof Name) { + return $this->resolveThrowType( + $methodReflection, + $scope->getType($methodCall->class)->getObjectClassReflections(), + ); + } + + $className = $scope->resolveName($methodCall->class); + + return $this->resolveThrowType( + $methodReflection, + $this->reflectionProvider->hasClass($className) ? [$this->reflectionProvider->getClass($className)] : [], + ); + } + + /** + * Determine whether an @method tag types the method, its class registers a macro of that name + * with a known exception, and its magic handler declares an exception the extension may replace. + */ + private function isTaggedMacroWithThrowType(MethodReflection $methodReflection): bool + { + $classReflection = $methodReflection->getDeclaringClass(); + $methodName = $methodReflection->getName(); + + return $methodReflection->getThrowType() !== null + && ! $classReflection->hasNativeMethod($methodName) + && array_key_exists($methodName, $classReflection->getMethodTags()) + && $this->macros->getThrowType($classReflection, $methodName) !== null; + } + + /** + * Resolve the exception of the macros registered on the given hosts. + * + * A host without a macro exception keeps the method's own exception. + * + * @param list $hosts + */ + private function resolveThrowType(MethodReflection $methodReflection, array $hosts): Type + { + // isTaggedMacroWithThrowType() requires the method's own exception. + /** @var Type $throwType */ + $throwType = $methodReflection->getThrowType(); + + $types = []; + + foreach ($hosts as $host) { + $types[] = $this->macros->getThrowType($host, $methodReflection->getName()) ?? $throwType; + } + + return $types === [] ? $throwType : TypeCombinator::union(...$types); + } +} diff --git a/src/head/src/PHPStan/RoutingMacros.php b/src/phpstan/src/Methods/Macros/RoutingMacros.php similarity index 98% rename from src/head/src/PHPStan/RoutingMacros.php rename to src/phpstan/src/Methods/Macros/RoutingMacros.php index d5cd549269..759f40c13b 100644 --- a/src/head/src/PHPStan/RoutingMacros.php +++ b/src/phpstan/src/Methods/Macros/RoutingMacros.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Head\PHPStan; +namespace Hypervel\PHPStan\Methods\Macros; use Hypervel\Head\Enums\RobotsRule; diff --git a/src/scout/src/PHPStan/SearchableMacros.php b/src/phpstan/src/Methods/Macros/SearchableMacros.php similarity index 91% rename from src/scout/src/PHPStan/SearchableMacros.php rename to src/phpstan/src/Methods/Macros/SearchableMacros.php index 3371190a56..06a465666e 100644 --- a/src/scout/src/PHPStan/SearchableMacros.php +++ b/src/phpstan/src/Methods/Macros/SearchableMacros.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Scout\PHPStan; +namespace Hypervel\PHPStan\Methods\Macros; /** * Declare the builder macros that SearchableScope adds, for static analysis. diff --git a/src/database/src/PHPStan/SoftDeletingMacros.php b/src/phpstan/src/Methods/Macros/SoftDeletingMacros.php similarity index 96% rename from src/database/src/PHPStan/SoftDeletingMacros.php rename to src/phpstan/src/Methods/Macros/SoftDeletingMacros.php index 6271c56d4f..9d6add8b6b 100644 --- a/src/database/src/PHPStan/SoftDeletingMacros.php +++ b/src/phpstan/src/Methods/Macros/SoftDeletingMacros.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods\Macros; use Hypervel\Database\Eloquent\Model; diff --git a/src/phpstan/src/Methods/ManagersMethodsExtension.php b/src/phpstan/src/Methods/ManagersMethodsExtension.php new file mode 100644 index 0000000000..eda8aba445 --- /dev/null +++ b/src/phpstan/src/Methods/ManagersMethodsExtension.php @@ -0,0 +1,225 @@ + */ + private array $cache = []; + + /** @var array */ + private array $driverTypes = []; + + /** + * Create a managers methods extension. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClosureTypeFactory $closureTypeFactory, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Determine whether the manager's default driver has the public method the manager forwards. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + if (! $classReflection->is(Manager::class) || $classReflection->isAbstract()) { + return false; + } + + // Hypervel: keyed by the specialization, because a generic manager's declared driver can depend on its template types. + $key = $classReflection->getCacheKey() . '-' . $methodName; + + if (isset($this->cache[$key])) { + return true; + } + + // Hypervel: @method tags and mixins declare the forwarded API, so they win over the booted driver. + if (ReflectionHelper::hasMethodTag($classReflection, $methodName)) { + return false; + } + + // Hypervel: typed from the driver's declared type, while upstream builds the driver. + $driverType = $this->getDriverType($classReflection); + + if ($driverType === null || ! $driverType->hasMethod($methodName)->yes()) { + return false; + } + + $method = $driverType->getMethod($methodName, new OutOfClassScope); + + // Hypervel: __call() runs in the manager, so it reaches only the driver's public methods. + if (! $method->isPublic()) { + return false; + } + + // Hypervel: an instance call with argument values, unlike upstream's StaticMethodReflection, because + // Manager has no __callStatic(). The driver's own static returns stay the driver's. + $this->cache[$key] = new ProxiedMethodReflection( + $classReflection, + $method, + static fn (Type $returnType): Type => $returnType, + ); + + return true; + } + + /** + * Get the driver method the manager forwards. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->cache[$classReflection->getCacheKey() . '-' . $methodName]; + } + + /** + * Get the declared type of the booted manager's default driver, resolving it once per manager. + */ + private function getDriverType(ClassReflection $classReflection): ?Type + { + $key = $classReflection->getCacheKey(); + + if (! array_key_exists($key, $this->driverTypes)) { + $this->runtimeInputs->track($classReflection); + + $driverType = $this->resolveDriverType($classReflection); + + foreach ($driverType?->getObjectClassNames() ?? [] as $driverClass) { + $this->runtimeInputs->trackClass($classReflection, $driverClass); + } + + $this->driverTypes[$key] = $driverType; + } + + return $this->driverTypes[$key]; + } + + /** + * Resolve the declared type of the booted manager's default driver, following Manager::driver(). + */ + private function resolveDriverType(ClassReflection $classReflection): ?Type + { + $manager = $this->resolve($classReflection->getName()); + + // The container can bind the manager to a subclass, whose methods then do the forwarding. A manager + // that does not resolve still types an overriding driver() from its declaration. + $managerReflection = $classReflection; + + if ($manager instanceof Manager) { + // The booted manager's own methods, such as getDefaultDriver(), select the driver. + $this->runtimeInputs->trackSources($classReflection, $manager::class, Manager::class); + + if ($manager::class !== $classReflection->getName()) { + $managerReflection = $this->reflectionProvider->getClass($manager::class); + } + } + + // A Macroable or custom __call() keeps its own API instead of forwarding to the driver. + if ($this->overrides($managerReflection, '__call')) { + return null; + } + + // An override's declared return types what it selects or creates. Its body is never interpreted. + if ($this->overrides($managerReflection, 'driver')) { + return $this->getReturnType($managerReflection, 'driver'); + } + + if (! $manager instanceof Manager) { + return null; + } + + try { + $driver = $manager->getDefaultDriver(); + } catch (InvalidArgumentException) { + // The manager has no default driver, so nothing is forwarded. + return null; + } + + if ($driver === null) { + return null; + } + + /** @var null|Closure $creator */ + $creator = (fn (): ?Closure => $this->customCreators[$driver] ?? null)->call($manager); + + if ($this->overrides($managerReflection, 'createDriver')) { + $type = $this->getReturnType($managerReflection, 'createDriver'); + } elseif ($creator !== null) { + $type = $this->overrides($managerReflection, 'callCustomCreator') + ? $this->getReturnType($managerReflection, 'callCustomCreator') + : $this->closureTypeFactory->fromClosureObject($creator)->getReturnType(); + } elseif ($this->overrides($managerReflection, 'getCreatorMethod')) { + return null; + } else { + $method = (fn (): ?string => $this->getCreatorMethod($driver))->call($manager); + + if ($method === null) { + return null; + } + + $type = $this->getReturnType($managerReflection, $method); + } + + // Manager::driver() keeps returning a driver it already created, even after extend() registers another creator. + // Its class is exact, so a parent of the computed type does not fit either. + $cached = $manager->getDrivers()[$driver] ?? null; + + if ($cached !== null && ! $type->isSuperTypeOf(ConstantTypeHelper::getTypeFromValue($cached))->yes()) { + return null; + } + + return $type; + } + + /** + * Determine whether the manager overrides the given Manager method. + */ + private function overrides(ClassReflection $classReflection, string $methodName): bool + { + return $classReflection->getNativeMethod($methodName)->getDeclaringClass()->getName() !== Manager::class; + } + + /** + * Get the declared return type of the manager's method. + */ + private function getReturnType(ClassReflection $classReflection, string $methodName): Type + { + return $classReflection->getNativeMethod($methodName)->getOnlyVariant()->getReturnType(); + } +} diff --git a/src/phpstan/src/Methods/ModelFactoryMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/ModelFactoryMethodsClassReflectionExtension.php new file mode 100644 index 0000000000..c0cfc91f2a --- /dev/null +++ b/src/phpstan/src/Methods/ModelFactoryMethodsClassReflectionExtension.php @@ -0,0 +1,245 @@ +is(Factory::class)) { + return false; + } + + // Unlike upstream, which reads only the class and its direct parent, a factory extending another + // factory reads TModel too. + $modelType = $classReflection->getAncestorWithClassName(Factory::class)?->getActiveTemplateTypeMap()->getType('TModel'); + + if ($modelType === null) { + return false; + } + + if ($modelType->getObjectClassReflections() !== []) { + $modelReflection = $modelType->getObjectClassReflections()[0]; + } else { + $modelReflection = $this->reflectionProvider->getClass(Model::class); + } + + if ($methodName === 'trashed' && array_key_exists(SoftDeletes::class, $modelReflection->getTraits(true))) { + return true; + } + + if (! Str::startsWith($methodName, ['for', 'has'])) { + return false; + } + + $relationship = Str::camel(Str::substr($methodName, 3)); + + if (! $modelType->hasMethod($relationship)->yes()) { + return false; + } + + // Unlike upstream, the method must be able to return a relation, whose related model __call() reads. + $returnType = $modelType->getMethod($relationship, new OutOfClassScope)->getVariants()[0]->getReturnType(); + + return ! (new ObjectType(Relation::class))->isSuperTypeOf($returnType)->no(); + } + + /** + * Get the dynamic factory method. + */ + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + return new class($classReflection, $methodName) implements MethodReflection { + /** + * Create the dynamic factory method reflection. + */ + public function __construct(private ClassReflection $classReflection, private string $methodName) + { + } + + /** + * Get the class declaring the method. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->classReflection; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Get the method's doc comment. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Get the method name. + */ + public function getName(): string + { + return $this->methodName; + } + + /** + * Get the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Get the method's signatures. + * + * @return ParametersAcceptor[] + */ + public function getVariants(): array + { + $returnType = new ObjectType($this->classReflection->getName(), null, $this->classReflection); + + $variants = [ + new FunctionVariant(TemplateTypeMap::createEmpty(), null, [], false, $returnType), + ]; + + // Unlike upstream, which gives it the has* signatures, trashed() takes the deleted-at value, + // which set() would take. + if ($this->methodName === 'trashed') { + $valueParameter = $this->classReflection->getMethod('set', new OutOfClassScope)->getVariants()[0]->getParameters()[1]; + + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$valueParameter], false, $returnType); + + return $variants; + } + + $stateParameter = $this->classReflection->getMethod('state', new OutOfClassScope)->getVariants()[0]->getParameters()[0]; + + if (Str::startsWith($this->methodName, 'for')) { + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$stateParameter], false, $returnType); + + return $variants; + } + + $countParameter = $this->classReflection->getMethod('count', new OutOfClassScope)->getVariants()[0]->getParameters()[0]; + $sequenceParameter = $this->classReflection->getMethod('forEachSequence', new OutOfClassScope)->getVariants()[0]->getParameters()[0]; + + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$countParameter], false, $returnType); + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$stateParameter], false, $returnType); + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$countParameter, $stateParameter], false, $returnType); + // Unlike upstream, several arrays are accepted: __call() passes them to forEachSequence(). + $variants[] = new FunctionVariant(TemplateTypeMap::createEmpty(), null, [$sequenceParameter], true, $returnType); + + return $variants; + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Get the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return null; + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Get the type the method throws. + */ + public function getThrowType(): ?Type + { + return null; + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + }; + } +} diff --git a/src/phpstan/src/Methods/ModelScopeMethodResolver.php b/src/phpstan/src/Methods/ModelScopeMethodResolver.php new file mode 100644 index 0000000000..230ec0f9cc --- /dev/null +++ b/src/phpstan/src/Methods/ModelScopeMethodResolver.php @@ -0,0 +1,109 @@ +> */ + private array $methods = []; + + /** + * Resolve a model scope by its exposed name. + */ + public function resolve(ClassReflection $modelClass, string $scope): ?MethodReflection + { + return $this->methods($modelClass)[strtolower($scope)] ?? null; + } + + /** + * Resolve a scope for a model type, and the class declaring it. + * + * An intersection such as Model&HasTimeline can declare the scope through an interface. A union is declined, + * as its members can resolve the name differently. + * + * @return null|array{ClassReflection, MethodReflection} + */ + public function resolveForType(Type $modelType, string $scope): ?array + { + $types = TypeUtils::flattenTypes($modelType); + + if (count($types) !== 1) { + return null; + } + + $classes = $types[0]->getObjectClassReflections(); + $modelClasses = array_filter($classes, static fn (ClassReflection $class): bool => $class->is(Model::class)); + + // The model's own scopes win, as Eloquent checks the model's attributed scopes first. + foreach ([...$modelClasses, ...array_diff_key($classes, $modelClasses)] as $class) { + $method = $this->resolve($class, $scope); + + if ($method !== null) { + return [$class, $method]; + } + } + + return null; + } + + /** + * Determine whether a method's declared name makes it a legacy scope, as Model::isLegacyScopeMethod() reads it. + */ + public static function isLegacyScopeName(string $methodName): bool + { + return strncasecmp($methodName, 'scope', 5) === 0 + && strlen($methodName) > 5 + && ! ctype_lower($methodName[5]); + } + + /** + * Return the named scopes exposed by a model. + * + * @return array + */ + private function methods(ClassReflection $modelClass): array + { + $cacheKey = $modelClass->getCacheKey(); + + if (isset($this->methods[$cacheKey])) { + return $this->methods[$cacheKey]; + } + + $legacyScopes = []; + $attributedScopes = []; + + // Unlike upstream, an @method scopeX tag creates no scope: Model::hasNamedScope() needs a real method. + foreach ($modelClass->getNativeReflection()->getMethods() as $method) { + if ($method->isPrivate()) { + continue; + } + + $methodName = $method->getName(); + + if (self::isLegacyScopeName($methodName)) { + $legacyScopes[strtolower(substr($methodName, 5))] = $modelClass->getNativeMethod($methodName); + } + + // An implementation does not inherit an interface method's attributes. + if (! $modelClass->isInterface() && $method->getAttributes(Scope::class) !== []) { + $attributedScopes[strtolower($methodName)] = $modelClass->getNativeMethod($methodName); + } + } + + return $this->methods[$cacheKey] = array_replace($legacyScopes, $attributedScopes); + } +} diff --git a/src/database/src/PHPStan/ModelScopeTypeResolver.php b/src/phpstan/src/Methods/ModelScopeTypeResolver.php similarity index 95% rename from src/database/src/PHPStan/ModelScopeTypeResolver.php rename to src/phpstan/src/Methods/ModelScopeTypeResolver.php index 49dd9c6d2f..e1f022b8a9 100644 --- a/src/database/src/PHPStan/ModelScopeTypeResolver.php +++ b/src/phpstan/src/Methods/ModelScopeTypeResolver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods; use PHPStan\Reflection\ClassReflection; use PHPStan\Type\StaticType; diff --git a/src/database/src/PHPStan/NamedScopeMethodExtension.php b/src/phpstan/src/Methods/NamedScopeMethodExtension.php similarity index 53% rename from src/database/src/PHPStan/NamedScopeMethodExtension.php rename to src/phpstan/src/Methods/NamedScopeMethodExtension.php index a04ec93bf5..0988708545 100644 --- a/src/database/src/PHPStan/NamedScopeMethodExtension.php +++ b/src/phpstan/src/Methods/NamedScopeMethodExtension.php @@ -2,39 +2,40 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Methods; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\PHPStan\Reflection\NamedScopeMethodReflection; use LogicException; -use PHPStan\Analyser\OutOfClassScope; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; -use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\StaticType; use PHPStan\Type\ThisType; use PHPStan\Type\Type; /** * Describe model scopes exposed through models, builders, and relations. */ +#[AutowiredService] class NamedScopeMethodExtension implements MethodsClassReflectionExtension { /** @var array */ private array $methods = []; - private readonly OutOfClassScope $scope; - /** * Create a named scope method extension. */ public function __construct( - private readonly ReflectionProvider $reflectionProvider, private readonly ModelScopeMethodResolver $scopeMethods, private readonly BuilderMacroResolver $builderMacros, + private readonly BuilderHelper $builderHelper, + private readonly MacroMethodsClassReflectionExtension $macroMethods, + private readonly DynamicRelationMethodExtension $dynamicRelations, ) { - $this->scope = new OutOfClassScope; } /** @@ -63,11 +64,9 @@ public function getMethod(ClassReflection $classReflection, string $methodName): */ private function resolveMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection { - $isModelHost = $this->isClassOrSubclassOf($classReflection, Model::class); - $isBuilderHost = ! $isModelHost - && $this->isClassOrSubclassOf($classReflection, Builder::class); - $isRelationHost = ! $isModelHost && ! $isBuilderHost - && $this->isClassOrSubclassOf($classReflection, Relation::class); + $isModelHost = $classReflection->is(Model::class); + $isBuilderHost = ! $isModelHost && $classReflection->is(Builder::class); + $isRelationHost = ! $isModelHost && ! $isBuilderHost && $classReflection->is(Relation::class); if (! $isModelHost && ! $isBuilderHost && ! $isRelationHost) { return null; @@ -88,18 +87,18 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN return null; } - [$modelClass, $receiverType, $queryType, $static] = $host; - $scopeMethod = $this->scopeMethods->resolve($modelClass, $methodName); + [$modelType, $receiverType, $queryType, $static] = $host; + $scope = $this->scopeMethods->resolveForType($modelType, $methodName); $method = null; // Local builder macros run before named scopes in Builder::__call(). - if ($scopeMethod !== null - && ! $this->builderMacros->hasMacro($modelClass, $methodName) - && ! $this->nativeMethodTakesPrecedence($classReflection, $methodName, $queryType, $static)) { + if ($scope !== null + && ! $this->builderMacros->hasMacro($scope[0], $methodName) + && ! $this->methodTakesPrecedence($classReflection, $methodName, $queryType, $static)) { $method = new NamedScopeMethodReflection( $classReflection, - $modelClass, - $scopeMethod, + $scope[0], + $scope[1], $methodName, $receiverType, $queryType, @@ -115,7 +114,7 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN /** * Resolve the model, receiver and query types, and static mode for an Eloquent host. * - * @return null|array{ClassReflection, Type, Type, bool} + * @return null|array{Type, Type, Type, bool} */ private function host( ClassReflection $classReflection, @@ -126,53 +125,60 @@ private function host( $queryType = $this->activeBuilderType($classReflection); return [ - $classReflection, + (new StaticType($classReflection))->getStaticObjectType(), $queryType, $queryType, true, ]; } - $templateName = $isBuilderHost ? 'TModel' : 'TRelatedModel'; - - $modelType = $this->templateType( + $modelType = $this->builderHelper->templateType( $classReflection, $isBuilderHost ? Builder::class : Relation::class, - $templateName, + $isBuilderHost ? 'TModel' : 'TRelatedModel', ); - $modelClasses = $modelType->getObjectClassReflections(); + $receiverType = new ThisType($classReflection); - if (count($modelClasses) !== 1) { - return null; + if ($isBuilderHost) { + return [$modelType, $receiverType, $receiverType, false]; } - $receiverType = new ThisType($classReflection); + // An intersection such as Model&HasTimeline still names one model, whose builder the relation forwards to. + $modelClasses = array_values(array_filter( + $modelType->getObjectClassReflections(), + static fn (ClassReflection $modelClass): bool => $modelClass->is(Model::class), + )); - return [ - $modelClasses[0], - $receiverType, - $isBuilderHost ? $receiverType : $this->activeBuilderType($modelClasses[0]), - false, - ]; + return count($modelClasses) === 1 + ? [$modelType, $receiverType, $this->activeBuilderType($modelClasses[0]), false] + : null; } /** - * Determine whether a native method wins over dynamic scope dispatch. + * Determine whether a native method or a global builder macro wins over dynamic scope dispatch. */ - private function nativeMethodTakesPrecedence( + private function methodTakesPrecedence( ClassReflection $classReflection, string $methodName, Type $queryType, bool $static, ): bool { - if ($classReflection->hasNativeMethod($methodName) - && (! $static || $classReflection->getNativeMethod($methodName)->isPublic())) { + // PHPStan asks this extension only for calls that cannot reach the native method, and a protected + // method is unreachable from outside, so the host's __call() runs the scope. + if ($classReflection->hasNativeMethod($methodName) && $classReflection->getNativeMethod($methodName)->isPublic()) { + return true; + } + + // A model's __call() runs dynamic relations and through{Relation}() before forwarding to its builder. + if ($static && $this->dynamicRelations->hasMethod($classReflection, $methodName)) { return true; } - // Model and relation hosts also yield to methods owned by their active builder. + // Every host also yields to its active builder's public methods (a protected one is unreachable from outside, + // so Builder::__call() runs), and Builder::__call() runs global macros first. foreach ($queryType->getObjectClassReflections() as $builderClass) { - if ($builderClass->hasNativeMethod($methodName)) { + if (($builderClass->hasNativeMethod($methodName) && $builderClass->getNativeMethod($methodName)->isPublic()) + || $this->macroMethods->hasMethod($builderClass, $methodName)) { return true; } } @@ -181,47 +187,12 @@ private function nativeMethodTakesPrecedence( } /** - * Return the active query builder type for a model. + * Return the model's builder type. * - * Bind static here because scope returns are compared before PHPStan can rebind the synthesized receiver. + * Bind the model here because scope returns are compared before PHPStan can rebind the synthesized receiver. */ private function activeBuilderType(ClassReflection $modelClass): Type { - $variants = $modelClass->getMethod('query', $this->scope)->getVariants(); - - return ModelScopeTypeResolver::bindToModel($variants[0]->getReturnType(), $modelClass); - } - - /** - * Return an active template type from a class ancestor. - * - * @param class-string $ancestorClass - */ - private function templateType( - ClassReflection $classReflection, - string $ancestorClass, - string $templateName, - ): Type { - $type = $classReflection - ->getAncestorWithClassName($ancestorClass) - ?->getActiveTemplateTypeMap() - ->getType($templateName); - - return $type ?? throw new LogicException(sprintf( - 'Template type [%s] is not available for [%s].', - $templateName, - $classReflection->getName(), - )); - } - - /** - * Determine whether a class is or extends the target class. - * - * @param class-string $targetClass - */ - private function isClassOrSubclassOf(ClassReflection $classReflection, string $targetClass): bool - { - return $classReflection->getName() === $targetClass - || $classReflection->isSubclassOfClass($this->reflectionProvider->getClass($targetClass)); + return $this->builderHelper->determineBuilderClass($modelClass->getName(), (new StaticType($modelClass))->getStaticObjectType()); } } diff --git a/src/phpstan/src/Methods/OptionalExtension.php b/src/phpstan/src/Methods/OptionalExtension.php new file mode 100644 index 0000000000..e80d73e911 --- /dev/null +++ b/src/phpstan/src/Methods/OptionalExtension.php @@ -0,0 +1,105 @@ +getTargetType($classReflection); + + if ($targetType === null) { + return false; + } + + $this->runtimeInputs->track($classReflection); + + // Optional::__call() runs a macro before the held object's method, so the macro extension + // resolves it. The Optional calls the held object's method from outside the object's class. + return ! Optional::hasMacro($methodName) + && $targetType->hasMethod($methodName)->yes() + && $targetType->getMethod($methodName, new OutOfClassScope)->isPublic(); + } + + /** + * Get the held object's method, returning what the Optional returns for it. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + // hasMethod() accepts only Optionals with a value type. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + + /** @var Type $valueType */ + $valueType = $classReflection->getActiveTemplateTypeMap()->getType('TValue'); + $isObject = $valueType->isObject(); + + return new ProxiedMethodReflection( + $classReflection, + $targetType->getMethod($methodName, new OutOfClassScope), + static fn (Type $returnType): Type => match (true) { + $isObject->no() => new NullType, + $isObject->yes() => $returnType, + default => TypeCombinator::addNull($returnType), + }, + ); + } + + /** + * Get the type of the object the Optional may hold. + */ + private function getTargetType(ClassReflection $classReflection): ?Type + { + if ($classReflection->getName() !== Optional::class) { + return null; + } + + $valueType = $classReflection->getActiveTemplateTypeMap()->getType('TValue'); + + if ($valueType === null) { + return null; + } + + // A value that is never an object accepts any call, as the Optional skips it. + if ($valueType->isObject()->no()) { + return new MixedType; + } + + $objectType = TypeCombinator::intersect($valueType, new ObjectWithoutClassType); + + // An object of unknown class may have any method, like a mixed value. + return $objectType->getObjectClassNames() === [] ? new MixedType : $objectType; + } +} diff --git a/src/phpstan/src/Methods/RedirectResponseMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/RedirectResponseMethodsClassReflectionExtension.php new file mode 100644 index 0000000000..53af51b45d --- /dev/null +++ b/src/phpstan/src/Methods/RedirectResponseMethodsClassReflectionExtension.php @@ -0,0 +1,45 @@ +is(RedirectResponse::class)) { + return false; + } + + if (! str_starts_with($methodName, 'with')) { + return false; + } + + return true; + } + + /** + * Get the reflection of the dynamic with*() method. + */ + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + return new DynamicWhereMethodReflection($classReflection, $methodName); + } +} diff --git a/src/phpstan/src/Methods/RedisCommandMethodsExtension.php b/src/phpstan/src/Methods/RedisCommandMethodsExtension.php new file mode 100644 index 0000000000..7ea6c92208 --- /dev/null +++ b/src/phpstan/src/Methods/RedisCommandMethodsExtension.php @@ -0,0 +1,122 @@ + + */ + private ?array $commandNames = null; + + /** + * Create a new extension instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Determine whether the method is a documented Redis command in another casing. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + $commandName = $this->getCommandName($classReflection, $methodName); + + return $commandName !== null && $classReflection->hasMethod($commandName); + } + + /** + * Get the documented command's reflection, as the receiver resolves it. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + $method = $classReflection->getMethod($this->getCommandNames()[strtolower($methodName)], new OutOfClassScope); + + // The facade's tags are its own. A connection's tag is RedisConnection's, and PHPStan looks the + // called name up again on a method's declaring class, whose macros may differ from the receiver's. + return $classReflection->getName() === Redis::class + ? $method + : new ProxiedMethodReflection($classReflection, $method, static fn (Type $returnType): Type => $returnType); + } + + /** + * Get the tag name of the command that the method name calls in another casing. + */ + private function getCommandName(ClassReflection $classReflection, string $methodName): ?string + { + if ($classReflection->is(RedisConnection::class)) { + // Macroable keeps macros in static::$macros, which a subclass shares unless it declares its own. + $macroClass = $classReflection->getNativeProperty('macros')->getDeclaringClass()->getName(); + } elseif ($classReflection->getName() === Redis::class) { + // The facade's calls reach the pooled connections, which share RedisConnection's macros. + $macroClass = RedisConnection::class; + } else { + return null; + } + + $commandName = $this->getCommandNames()[strtolower($methodName)] ?? null; + + if ($commandName === null || $commandName === $methodName) { + return null; + } + + $this->runtimeInputs->track($classReflection); + + // __call() runs a macro registered under the exact name before it lowercases the name. Macros are + // registered at runtime, so the registry of an owner the booted application never loaded is unavailable. + if (class_exists($macroClass, false) && $macroClass::hasMacro($methodName)) { + return null; + } + + return $commandName; + } + + /** + * Get RedisConnection's command tag names, keyed by their lowercase form. + * + * @return array + */ + private function getCommandNames(): array + { + if ($this->commandNames === null) { + $this->commandNames = []; + + foreach ($this->reflectionProvider->getClass(RedisConnection::class)->getMethodTags() as $name => $tag) { + $this->commandNames[strtolower($name)] = $name; + } + } + + return $this->commandNames; + } +} diff --git a/src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php new file mode 100644 index 0000000000..391bae38e9 --- /dev/null +++ b/src/phpstan/src/Methods/StorageMethodsClassReflectionExtension.php @@ -0,0 +1,84 @@ +getName() !== Storage::class) { + return false; + } + + return $this->getTargetType($classReflection, $methodName)->hasMethod($methodName)->yes(); + } + + /** + * Get the forwarded method as a static facade method. + */ + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + return new StaticMethodReflection($this->getTargetType($classReflection, $methodName)->getMethod($methodName, new OutOfClassScope)); + } + + /** + * Get the type whose method a facade call runs: the manager's own, else the default disk's. + * + * Hypervel: upstream checks the manager with its adapter mixins first, then FilesystemAdapter. Those + * mixins would shadow the default disk, whose methods differ for a pooled disk, so the manager's + * declared mixins only apply when the default disk cannot be typed. + */ + private function getTargetType(ClassReflection $classReflection, string $methodName): Type + { + $manager = $this->reflectionProvider->getClass(FilesystemManager::class); + + if ($manager->hasNativeMethod($methodName)) { + return new ObjectType(FilesystemManager::class); + } + + $diskType = $this->diskHelper->getDefaultDiskType($classReflection); + + if ($diskType === null) { + return new ObjectType(FilesystemManager::class); + } + + // The disk classes supply the facade's forwarded methods, so its callers depend on them. + foreach ($diskType->getObjectClassNames() as $diskClass) { + $this->runtimeInputs->trackClass($classReflection, $diskClass); + } + + return $diskType; + } +} diff --git a/src/phpstan/src/Methods/ViewWithMethodsClassReflectionExtension.php b/src/phpstan/src/Methods/ViewWithMethodsClassReflectionExtension.php new file mode 100644 index 0000000000..93eb43a198 --- /dev/null +++ b/src/phpstan/src/Methods/ViewWithMethodsClassReflectionExtension.php @@ -0,0 +1,46 @@ +is(View::class)) { + return false; + } + + if (! str_starts_with($methodName, 'with')) { + return false; + } + + return true; + } + + /** + * Get the reflection of the dynamic with*() method. + */ + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + return new DynamicWhereMethodReflection($classReflection, $methodName); + } +} diff --git a/src/head/src/PHPStan/WithHeadExtension.php b/src/phpstan/src/Methods/WithHeadExtension.php similarity index 80% rename from src/head/src/PHPStan/WithHeadExtension.php rename to src/phpstan/src/Methods/WithHeadExtension.php index bc4edc3e99..20d59878ab 100644 --- a/src/head/src/PHPStan/WithHeadExtension.php +++ b/src/phpstan/src/Methods/WithHeadExtension.php @@ -2,14 +2,18 @@ declare(strict_types=1); -namespace Hypervel\Head\PHPStan; +namespace Hypervel\PHPStan\Methods; +use Hypervel\Head\Routing\HeadRouteMacros; +use Hypervel\PHPStan\Methods\Macros\RoutingMacros; +use Hypervel\PHPStan\Reflection\WithHeadMethodReflection; use Hypervel\Routing\PendingResourceRegistration; use Hypervel\Routing\PendingSingletonResourceRegistration; use Hypervel\Routing\Route; use Hypervel\Routing\Router; use Hypervel\Routing\RouteRegistrar; use Hypervel\Support\Facades\Route as RouteFacade; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; @@ -17,6 +21,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\ThisType; +#[AutowiredService] class WithHeadExtension implements MethodsClassReflectionExtension { /** @@ -31,7 +36,10 @@ public function __construct(protected readonly ReflectionProvider $reflectionPro */ public function hasMethod(ClassReflection $classReflection, string $methodName): bool { - if ($methodName !== 'withHead' || $classReflection->hasNativeMethod($methodName)) { + // Head is optional; without it, no withHead() macro is registered. + if ($methodName !== 'withHead' + || $classReflection->hasNativeMethod($methodName) + || ! $this->reflectionProvider->hasClass(HeadRouteMacros::class)) { return false; } diff --git a/src/phpstan/src/Properties/ConnectionConfig.php b/src/phpstan/src/Properties/ConnectionConfig.php new file mode 100644 index 0000000000..4e0b527bf8 --- /dev/null +++ b/src/phpstan/src/Properties/ConnectionConfig.php @@ -0,0 +1,86 @@ + $searchPath the schemas an unqualified PostgreSQL name resolves through, in order + * @param bool $nativeJson whether SQLite's schema grammar creates Blueprint json columns as json rather than text + * @param bool $nativeJsonb whether SQLite's schema grammar creates Blueprint jsonb columns as jsonb rather than text + * @param list> $fetchOptions each distinct set of the PDO options that change fetched values + * @param bool $customized whether a DB::extend() resolver or a custom connector creates the connection, so fetched values are unknown + */ + public function __construct( + public string $owner, + public string $driver, + public string $prefix, + public ?string $defaultSchema, + public array $searchPath, + public bool $nativeJson, + public bool $nativeJsonb, + public array $fetchOptions, + public bool $customized, + ) { + } + + /** + * Create the schema parser for the connection's driver, or return null when the driver has none. + */ + public function createParser(): ?Parser + { + return match ($this->driver) { + 'mysql' => new MySqlParser, + 'mariadb' => new MariaDbParser, + 'pgsql' => new PostgresParser, + 'sqlite' => new SQLiteParser, + default => null, + }; + } + + /** + * Get the schema an unqualified definition belongs to: the MySQL database or SQLite's main database. + * + * PostgreSQL definitions stay unqualified with the connection's search path. + */ + public function definitionSchema(): ?string + { + return $this->driver === 'pgsql' ? null : $this->defaultSchema; + } + + /** + * Get the schema and physical name of a logical table name. + * + * The prefix applies to the final segment, as Grammar::wrapTable() applies it. + * + * @return array{?string, string} + */ + public function physicalTable(string $table): array + { + $separator = strrpos($table, '.'); + + return $separator === false + ? [null, $this->prefix . $table] + : [substr($table, 0, $separator), $this->prefix . substr($table, $separator + 1)]; + } +} diff --git a/src/phpstan/src/Properties/ConnectionConfigResolver.php b/src/phpstan/src/Properties/ConnectionConfigResolver.php new file mode 100644 index 0000000000..10e02d6f82 --- /dev/null +++ b/src/phpstan/src/Properties/ConnectionConfigResolver.php @@ -0,0 +1,296 @@ + true, + PDO::ATTR_ORACLE_NULLS => true, + PDO::ATTR_STRINGIFY_FETCHES => true, + ]; + + /** @var array */ + private array $connections = []; + + /** @var array the config keys read, whose sources the schema facts depend on */ + private array $consumedKeys = []; + + /** @var array each config key read as a connection name, with its state */ + private array $configuredNames = []; + + /** + * Get the facts of a connection name, or null when its configuration cannot be resolved. + */ + public function connection(string $name): ?ConnectionConfig + { + if (array_key_exists($name, $this->connections)) { + return $this->connections[$name]; + } + + return $this->connections[$name] = $this->resolveConnection($name); + } + + /** + * Get the name of the default connection. + */ + public function getDefaultConnectionName(): ?string + { + $name = $this->config('database.default'); + + return is_string($name) ? $name : null; + } + + /** + * Get the names of the configured connections. + * + * @return list + */ + public function getConnectionNames(): array + { + $connections = $this->config('database.connections'); + + return is_array($connections) ? array_map('strval', array_keys($connections)) : []; + } + + /** + * Get whether a config key holding a connection name is present, and its value when it + * is a string or null, or false for any other value. + * + * @return array{bool, null|false|string} + */ + public function getConfiguredName(string $key): array + { + $value = $this->config($key); + + return $this->configuredNames[$key] = [ + $this->getContainer()->make('config')->has($key), + is_string($value) || $value === null ? $value : false, + ]; + } + + /** + * Get each config key read as a connection name, with its state. + * + * @return array + */ + public function getConfiguredNames(): array + { + return $this->configuredNames; + } + + /** + * Get the config keys read so far. + * + * @return list + */ + public function getConsumedKeys(): array + { + return array_keys($this->consumedKeys); + } + + /** + * Get the normalized configuration that schema aggregation reads. + * + * Fetch options and custom resolvers only affect lookups, so changing them keeps the aggregated schema. + * + * @return array + */ + public function getSchemaConfiguration(): array + { + $names = $this->getConnectionNames(); + + return [ + 'default' => $this->getDefaultConnectionName(), + 'connections' => array_combine($names, array_map(function (string $name): ?array { + $connection = $this->connection($name); + + return $connection === null ? null : [ + $connection->owner, + $connection->driver, + $connection->prefix, + $connection->defaultSchema, + $connection->searchPath, + $connection->nativeJson, + $connection->nativeJsonb, + ]; + }, $names)), + 'morphKeyType' => Builder::$defaultMorphKeyType, + ]; + } + + /** + * Resolve the facts of a connection name. + */ + private function resolveConnection(string $name): ?ConnectionConfig + { + try { + $connectionName = ConnectionName::parse($name); + $owner = Migrator::resolveMigrationConnectionName($connectionName->base); + } catch (InvalidArgumentException) { + return null; + } + + $config = $this->config("database.connections.{$connectionName->base}"); + + if (! is_array($config)) { + return null; + } + + /** @var ConnectionFactory $factory */ + $factory = $this->getContainer()->make('db.factory'); + $config = $factory->parseConfig($config, $connectionName->base); + $records = $this->getRecords($factory, $config, $connectionName); + $identities = array_unique(array_map(static fn (array $record): string => serialize([ + $record['driver'] ?? null, + $record['prefix'] ?? null, + $record['database'] ?? null, + $record['search_path'] ?? $record['schema'] ?? null, + $record['username'] ?? null, + (bool) ($record['use_native_json'] ?? false), + (bool) ($record['use_native_jsonb'] ?? false), + ]), $records)); + + // Lookups cannot tell which of several differing endpoints a query reaches. + if (count($identities) !== 1 || ! is_string($driver = $records[0]['driver'] ?? null)) { + return null; + } + + $record = $records[0]; + $customized = $factory->getExtension($config, $connectionName->base) !== null; + $fetchOptions = []; + + try { + $connector = $factory->createConnector($record); + } catch (InvalidArgumentException) { + $connector = null; + } + + if ($connector instanceof Connector) { + foreach ($records as $endpoint) { + $options = array_intersect_key($connector->getOptions($endpoint), self::FETCH_OPTIONS); + + if (! in_array($options, $fetchOptions, true)) { + $fetchOptions[] = $options; + } + } + } else { + $customized = true; + } + + return new ConnectionConfig( + owner: $owner, + driver: $driver, + prefix: (string) $record['prefix'], + defaultSchema: match ($driver) { + 'mysql', 'mariadb' => is_string($record['database'] ?? null) ? $record['database'] : null, + 'sqlite' => 'main', + default => null, + }, + searchPath: $driver === 'pgsql' ? $this->getSearchPath($record) : [], + nativeJson: (bool) ($record['use_native_json'] ?? false), + nativeJsonb: (bool) ($record['use_native_jsonb'] ?? false), + fetchOptions: $fetchOptions, + customized: $customized, + ); + } + + /** + * Get every endpoint configuration a connection name can select. + * + * A base name reads from either side, so it can reach every read and write record. + * + * @param array $config + * + * @return non-empty-list> + */ + private function getRecords(ConnectionFactory $factory, array $config, ConnectionName $name): array + { + if (! $factory->hasReadConfig($config)) { + return [$config]; + } + + $roles = match (true) { + $name->isRead() => ['read'], + $name->isWrite() => ['write'], + default => ['read', 'write'], + }; + + $records = []; + + // Mirrors DatabasePool::roleEndpoints(), which builds each record a role may select. + foreach ($roles as $role) { + foreach (isset($config[$role][0]) ? $config[$role] : [$config[$role]] as $record) { + $endpoint = array_replace($config, [$role => $record]); + $records[] = $role === 'read' ? $factory->configForRead($endpoint) : $factory->configForWrite($endpoint); + } + } + + return $records; + } + + /** + * Get the schemas an unqualified name resolves through, as PostgresBuilder::getCurrentSchemaListing() reads them. + * + * @param array $record + * + * @return list + */ + private function getSearchPath(array $record): array + { + return array_values(array_map( + static fn (string $schema): string => $schema === '$user' ? (string) ($record['username'] ?? '') : $schema, + $this->parseSearchPath($record['search_path'] ?? $record['schema'] ?? 'public'), + )); + } + + /** + * Read a config key from the booted application, recording it as consumed. + */ + private function config(string $key): mixed + { + $this->consumedKeys[$key] = true; + + return $this->getContainer()->make('config')->get($key); + } +} diff --git a/src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php b/src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php new file mode 100644 index 0000000000..477e12ea36 --- /dev/null +++ b/src/phpstan/src/Properties/HigherOrderCollectionProxyPropertyExtension.php @@ -0,0 +1,38 @@ +higherOrderCollectionProxyHelper->hasPropertyOrMethod($classReflection, $propertyName, 'property'); + } + + /** + * Get the property, typed as what the proxied collection method returns. + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->higherOrderCollectionProxyHelper->getProperty($classReflection, $propertyName); + } +} diff --git a/src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php b/src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php new file mode 100644 index 0000000000..8c300c2744 --- /dev/null +++ b/src/phpstan/src/Properties/HigherOrderWhenProxyPropertyExtension.php @@ -0,0 +1,38 @@ +higherOrderWhenProxyHelper->hasPropertyOrMethod($classReflection, $propertyName, 'property'); + } + + /** + * Get the property, typed as what the proxy returns for it. + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->higherOrderWhenProxyHelper->getProperty($classReflection, $propertyName); + } +} diff --git a/src/phpstan/src/Properties/MigrationCache.php b/src/phpstan/src/Properties/MigrationCache.php new file mode 100644 index 0000000000..1c6ef4fb7f --- /dev/null +++ b/src/phpstan/src/Properties/MigrationCache.php @@ -0,0 +1,280 @@ + $configuration the normalized configuration the aggregation reads + * @param callable(): array{connections: array, sources: array} $callback + * + * @return array{connections: array, sources: array} + */ + public function remember(array $migrationFiles, array $schemaFiles, array $configuration, callable $callback): array + { + if (! $this->enabled) { + return $callback(); + } + + $fingerprint = $this->generateFingerprint($migrationFiles, $schemaFiles, $configuration); + $cachePath = $this->getCachePath($fingerprint); + + $cached = file_exists($cachePath) ? $this->readFromCache($cachePath) : null; + + if ($cached !== null) { + return $cached; + } + + $lockHandle = $this->acquireExclusiveLock(); + + if ($lockHandle === false) { + return $callback(); + } + + try { + $cached = file_exists($cachePath) ? $this->readFromCache($cachePath) : null; + + if ($cached !== null) { + return $cached; + } + + $schema = $callback(); + $sources = $schema['sources']; + + foreach ($schema['connections'] as $connection) { + foreach ($connection->tables as $table) { + $sources += $table->sources; + } + } + + $sourceHashes = []; + + foreach (array_keys($sources) as $source) { + $sourceHashes[$source] = self::hashSource($source); + } + + $tempPath = sprintf('%s.tmp.%d', $cachePath, getmypid()); + + file_put_contents($tempPath, serialize([ + 'configuredNames' => $this->connectionConfigResolver->getConfiguredNames(), + 'sourceHashes' => $sourceHashes, + ...$schema, + ])); + rename($tempPath, $cachePath); + + $this->cleanupOldCacheFiles($fingerprint); + + return $schema; + } finally { + $this->releaseLock($lockHandle); + } + } + + /** + * Read a cached schema, or return null when it is missing or the config keys or PHP files it read have changed. + * + * @return null|array{connections: array, sources: array} + */ + private function readFromCache(string $path): ?array + { + $content = file_get_contents($path); + + if ($content === false) { + return null; + } + + /** @var array{configuredNames: array, sourceHashes: array, connections: array, sources: array}|false $data */ + $data = @unserialize($content); + + if (! is_array($data)) { + return null; + } + + foreach ($data['configuredNames'] as $key => $state) { + if ($this->connectionConfigResolver->getConfiguredName($key) !== $state) { + return null; + } + } + + foreach ($data['sourceHashes'] as $source => $hash) { + if (self::hashSource($source) !== $hash) { + return null; + } + } + + return ['connections' => $data['connections'], 'sources' => $data['sources']]; + } + + /** + * Get the content hash of a PHP file the aggregation read, or false when it no longer exists. + */ + private static function hashSource(string $path): false|string + { + return is_file($path) ? hash_file('xxh128', $path) : false; + } + + /** + * Delete the cached schemas of earlier inputs. + */ + private function cleanupOldCacheFiles(string $currentFingerprint): void + { + $currentCacheFile = sprintf('%s%s.cache', self::CACHE_PREFIX, $currentFingerprint); + $files = glob(sprintf('%s/%s*.cache', $this->cacheDirectory, self::CACHE_PREFIX)) ?: []; + + foreach (array_filter($files, static fn (string $file): bool => ! str_ends_with($file, $currentCacheFile)) as $file) { + @unlink($file); + } + } + + /** + * Generate the fingerprint of everything the aggregated schema depends on. + * + * @param SplFileInfo[] $migrationFiles + * @param SplFileInfo[] $schemaFiles + * @param array $configuration + */ + private function generateFingerprint(array $migrationFiles, array $schemaFiles, array $configuration): string + { + // The modification time has whole-second resolution, so two edits within one second + // would reuse a stale schema without the content digest. The files keep the order + // they are applied in, which decides the schema too. + $metadata = array_merge( + array_map(static fn (SplFileInfo $file): string => sprintf('M:%s:%d:%s', $file->getPathname(), $file->getMTime(), hash_file('xxh128', $file->getPathname())), $migrationFiles), + array_map(static fn (SplFileInfo $file): string => sprintf('S:%s:%d:%s', $file->getPathname(), $file->getMTime(), hash_file('xxh128', $file->getPathname())), $schemaFiles), + ); + + return hash('xxh128', implode('|', [ + self::CACHE_FORMAT_VERSION, + PHP_VERSION, + self::packageVersion('hypervel/phpstan'), + self::packageVersion('hypervel/schema-parser'), + serialize($configuration), + ...$metadata, + ])); + } + + /** + * Get the installed version and reference of a package. + */ + private static function packageVersion(string $package): string + { + try { + return (InstalledVersions::getVersion($package) ?? 'unknown') + . '@' . (InstalledVersions::getReference($package) ?? 'unknown'); + } catch (OutOfBoundsException) { + return 'unknown'; + } + } + + /** + * Get the cache file of a fingerprint. + */ + private function getCachePath(string $fingerprint): string + { + return sprintf('%s/%s%s.cache', $this->cacheDirectory, self::CACHE_PREFIX, $fingerprint); + } + + /** + * Get the file that serializes cache writes. + */ + private function getLockPath(): string + { + return sprintf('%s/hypervel_migrations.lock', $this->cacheDirectory); + } + + /** + * Acquire the exclusive cache write lock. + * + * @return false|resource + */ + private function acquireExclusiveLock(): mixed + { + $handle = @fopen($this->getLockPath(), 'c'); + + if ($handle === false) { + return false; + } + + if (! flock($handle, LOCK_EX)) { + fclose($handle); + + return false; + } + + return $handle; + } + + /** + * Release the cache write lock. + * + * @param resource $handle + */ + private function releaseLock(mixed $handle): void + { + flock($handle, LOCK_UN); + fclose($handle); + } +} diff --git a/src/phpstan/src/Properties/MigrationHelper.php b/src/phpstan/src/Properties/MigrationHelper.php new file mode 100644 index 0000000000..03db89a023 --- /dev/null +++ b/src/phpstan/src/Properties/MigrationHelper.php @@ -0,0 +1,173 @@ + */ + private ?array $migrationPaths = null; + + /** @var null|list */ + private ?array $migrationFiles = null; + + /** + * Create a new migration helper instance. + */ + public function __construct( + #[AutowiredParameter(ref: '@migrationsParser')] + private Parser $parser, + /** @var string[] */ + #[AutowiredParameter(ref: '%databaseMigrationsPath%')] + private array $databaseMigrationPath, + private FileHelper $fileHelper, + #[AutowiredParameter] + private bool $disableMigrationScan, + #[AutowiredParameter(ref: '@reflectionProvider')] + private ReflectionProvider $reflectionProvider, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ConnectionConfigResolver $connectionConfigResolver, + ) { + } + + /** + * Apply the migrations to the schemas of the connections they run on. + * + * @param array $connections the schemas loaded from dumps, by owning connection + * + * @return array + */ + public function initializeConnections(array $connections = []): array + { + return $this->aggregate($connections)->connections; + } + + /** + * Apply the migrations to the schemas of the connections they run on, keeping the PHP files they read. + * + * @param array $connections the schemas loaded from dumps, by owning connection + */ + public function aggregate(array $connections = []): SchemaAggregator + { + $schemaAggregator = new SchemaAggregator( + $this->reflectionProvider, + $this->initializerExprTypeResolver, + $this->connectionConfigResolver, + $this->parser, + $connections, + ); + + foreach ($this->getMigrationFiles() as $file) { + try { + $schemaAggregator->addStatements($this->parser->parseFile($file->getPathname())); + } catch (ParserErrorsException) { + // Unlike upstream, which skipped the file, its changes are unknown. + $schemaAggregator->invalidate(); + } + } + + return $schemaAggregator; + } + + /** + * Get the migration files, in the order they run. + * + * @return list + */ + public function getMigrationFiles(): array + { + if ($this->migrationFiles !== null) { + return $this->migrationFiles; + } + + $paths = $this->getMigrationPaths(); + + if ($this->databaseMigrationPath === [] && $paths !== []) { + // Unlike upstream, default discovery is migrate's: a later path's migration replaces an + // earlier one of the same name, so a published copy replaces its package's original. + return $this->migrationFiles = array_map( + static fn (string $file): SplFileInfo => new SplFileInfo($file), + array_values($this->getContainer()->make('migrator')->getMigrationFiles(array_keys($paths))), + ); + } + + $migrationFiles = []; + + foreach (array_keys($paths) as $directory) { + if (is_dir($directory)) { + $migrationFiles += iterator_to_array( + new RegexIterator( + new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)), + '/\.php$/i', + ), + ); + } + } + + uasort($migrationFiles, static fn (SplFileInfo $a, SplFileInfo $b): int => $a->getFilename() <=> $b->getFilename()); + + return $this->migrationFiles = array_values($migrationFiles); + } + + /** + * Get the paths migrations are discovered in, each mapped to the pattern its migration file names + * match, or to null for a migration file. + * + * By default these are the paths migrate reads: those providers register through loadMigrationsFrom(), + * then the application's migration directory, each holding its *_*.php files. Configured paths + * replace them and are directories scanned recursively for .php files, as upstream does; see + * DirectoryResolver::resolve(). + * + * @return array + */ + public function getMigrationPaths(): array + { + if ($this->disableMigrationScan) { + return []; + } + + if ($this->migrationPaths !== null) { + return $this->migrationPaths; + } + + if ($this->databaseMigrationPath !== []) { + return $this->migrationPaths = array_fill_keys(DirectoryResolver::resolve($this->databaseMigrationPath, $this->fileHelper), '*.[pP][hH][pP]'); + } + + $paths = []; + + foreach ([...$this->getContainer()->make('migrator')->paths(), database_path('migrations')] as $path) { + $paths[$path] = str_ends_with($path, '.php') ? null : '*_*.php'; + } + + return $this->migrationPaths = $paths; + } +} diff --git a/src/phpstan/src/Properties/ModelAccessorExtension.php b/src/phpstan/src/Properties/ModelAccessorExtension.php new file mode 100644 index 0000000000..8bbe0b38f4 --- /dev/null +++ b/src/phpstan/src/Properties/ModelAccessorExtension.php @@ -0,0 +1,39 @@ +modelPropertyHelper->hasAccessor($classReflection, $propertyName); + } + + /** + * Get the accessor property. + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->modelPropertyHelper->getAccessor($classReflection, $propertyName); + } +} diff --git a/src/phpstan/src/Properties/ModelCastHelper.php b/src/phpstan/src/Properties/ModelCastHelper.php new file mode 100644 index 0000000000..795519bf4d --- /dev/null +++ b/src/phpstan/src/Properties/ModelCastHelper.php @@ -0,0 +1,647 @@ +, complete: bool}> */ + private array $modelCasts = []; + + /** + * The primitive cast types Eloquent reads, which are never class casts. + * + * @var null|list + */ + private ?array $primitiveCastTypes = null; + + /** + * Create a new model cast helper instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + // Unlike upstream, the cached parser analysis uses, which keeps method bodies for every file. + #[AutowiredParameter(ref: '@cachedRichParser')] + private Parser $parser, + #[AutowiredParameter] + private bool $parseModelCastsMethod, + private ScopeFactory $scopeFactory, + private DeclarationDependencyTracker $declarationDependencyTracker, + private ModelHelper $modelHelper, + ) { + } + + /** + * Get the type a cast attribute reads as. + */ + public function getReadableType(string $cast, Type $originalType): Type + { + $arguments = $this->parseCastArguments($cast); + $cast = $this->parseCast($cast); + + $attributeType = match ($cast) { + 'int', 'integer', 'timestamp' => $originalType->isInteger()->yes() ? $originalType : new IntegerType, + 'real', 'float', 'double' => new FloatType, + 'decimal' => TypeCombinator::intersect(new StringType, new AccessoryNumericStringType), + 'string' => new StringType, + 'bool', 'boolean' => new BooleanType, + 'object' => new ObjectType(stdClass::class), + 'array', 'json' => new ArrayType(new BenevolentUnionType([new IntegerType, new StringType]), new MixedType), + 'collection' => new ObjectType(Collection::class), + 'date', 'datetime' => $this->getDateType(), + 'immutable_date', 'immutable_datetime' => new ObjectType(CarbonImmutable::class), + // The caster reads a missing or non-array value as null. + AsArrayObject::class, AsEncryptedArrayObject::class => new BenevolentUnionType([ + new ObjectType(ArrayObject::class), + new NullType, + ]), + AsCollection::class, AsEncryptedCollection::class => new BenevolentUnionType([ + $this->getCollectionCastType($arguments), + new NullType, + ]), + AsStringable::class => new ObjectType(HypervelStringable::class), + default => null, + }; + + if ($attributeType) { + return $attributeType; + } + + if (! $this->reflectionProvider->hasClass($cast)) { + return $originalType; + } + + $classReflection = $this->reflectionProvider->getClass($cast); + + if ($classReflection->isEnum()) { + return new ObjectType($cast); + } + + $caster = $this->resolveCaster($classReflection, $arguments); + + if ($caster?->is(CastsAttributes::class)) { + return $caster->getNativeMethod('get')->getVariants()[0]->getReturnType(); + } + + if ($caster?->is(CastsInboundAttributes::class)) { + return $originalType; + } + + return new MixedType; + } + + /** + * Get the type a cast attribute accepts. + */ + public function getWriteableType(string $cast, Type $originalType): Type + { + $arguments = $this->parseCastArguments($cast); + $cast = $this->parseCast($cast); + + $attributeType = match ($cast) { + 'int', 'integer', 'timestamp' => $originalType->isInteger()->yes() ? $originalType : new IntegerType, + 'real', 'float', 'double' => new FloatType, + 'decimal' => TypeCombinator::union(TypeCombinator::intersect(new StringType, new AccessoryNumericStringType), new IntegerType, new FloatType), + 'string' => new StringType, + 'bool', 'boolean' => TypeCombinator::union(new BooleanType, new ConstantIntegerType(0), new ConstantIntegerType(1)), + 'object' => new ObjectType(stdClass::class), + 'array', 'json' => new ArrayType(new BenevolentUnionType([new IntegerType, new StringType]), new MixedType), + 'collection' => new ObjectType(Collection::class), + 'date', 'datetime', 'immutable_date', 'immutable_datetime' => $this->getDateWriteableType(), + AsArrayObject::class, AsCollection::class, + AsEncryptedArrayObject::class, AsEncryptedCollection::class => new MixedType, + AsStringable::class => TypeCombinator::union(new StringType, new ObjectType(Stringable::class)), + default => null, + }; + + if ($attributeType) { + return $attributeType; + } + + if (! $this->reflectionProvider->hasClass($cast)) { + return $originalType; + } + + $classReflection = $this->reflectionProvider->getClass($cast); + + if ($classReflection->isEnum()) { + return $this->getEnumWriteableType($classReflection); + } + + $caster = $this->resolveCaster($classReflection, $arguments); + + if ( + $caster !== null + && ($caster->is(CastsAttributes::class) || $caster->is(CastsInboundAttributes::class)) + ) { + $methodReflection = $caster->getNativeMethod('set'); + $parameters = $methodReflection->getVariants()[0]->getParameters(); + + $valueParameter = Arr::first($parameters, static fn (ParameterReflection $parameterReflection) => $parameterReflection->getName() === 'value'); + + if ($valueParameter) { + return $valueParameter->getType(); + } + } + + return new MixedType; + } + + /** + * Get the type an enum cast attribute accepts: a case, or a value HasAttributes::getEnumCaseFromValue() resolves to one. + */ + private function getEnumWriteableType(ClassReflection $enum): Type + { + $enumType = new ObjectType($enum->getName()); + $backingType = $enum->getBackedEnumType(); + + return match (true) { + // A pure enum's case is looked up by name. + $backingType === null => TypeCombinator::union($enumType, new StringType), + // enum_from() reads a numeric string as an integer value. + $backingType->isInteger()->yes() => TypeCombinator::union( + $enumType, + new IntegerType, + TypeCombinator::intersect(new StringType, new AccessoryNumericStringType), + ), + // enum_from() reads an integer as its string value. + default => TypeCombinator::union($enumType, new StringType, new IntegerType), + }; + } + + /** + * Get the class that casts the attribute: the cast class itself, or the one caster its castUsing() returns. + * + * castUsing() is never called. A return that may name several classes, or one that is not known, leaves the + * caster undetermined. + * + * @param list $arguments + */ + private function resolveCaster(ClassReflection $classReflection, array $arguments): ?ClassReflection + { + if (! $classReflection->is(Castable::class)) { + return $classReflection; + } + + $casterType = ParametersAcceptorSelector::selectFromTypes( + [ConstantTypeHelper::getTypeFromValue($arguments)], + $classReflection->getNativeMethod('castUsing')->getVariants(), + false, + )->getReturnType()->getObjectTypeOrClassStringObjectType(); + $casters = $casterType->getObjectClassReflections(); + + return count($casters) === 1 && (new ObjectType($casters[0]->getName()))->isSuperTypeOf($casterType)->yes() + ? $casters[0] + : null; + } + + /** + * Get the collection a collection cast reads as: the class using() selects, with its templates at their bounds, + * or the base collection. + * + * Unlike upstream, the selected class is kept. With a mapper, map() and mapInto() can return another class. + * + * @param list $arguments + */ + private function getCollectionCastType(array $arguments): Type + { + $class = $arguments[0] ?? ''; + $collection = $class !== '' && ($arguments[1] ?? '') === '' && $this->reflectionProvider->hasClass($class) + ? $this->reflectionProvider->getClass($class) + : null; + + if ($collection === null || ! $collection->is(Collection::class) || $collection->getName() === Collection::class) { + return new GenericObjectType(Collection::class, [new BenevolentUnionType([new IntegerType, new StringType]), new MixedType]); + } + + $templateTypes = array_values($collection->getTemplateTypeMap()->resolveToBounds()->getTypes()); + + return $templateTypes === [] ? new ObjectType($collection->getName()) : new GenericObjectType($collection->getName(), $templateTypes); + } + + /** + * Get the type a date attribute reads as. + */ + public function getDateType(): Type + { + // Unlike upstream, the booted date factory's class is not used: applications can + // configure it, so asDateTime() only guarantees the interface. + return new ObjectType(CarbonInterface::class); + } + + /** + * Get the type a date attribute accepts, which fromDateTime() converts. + */ + public function getDateWriteableType(): Type + { + return TypeCombinator::union(new ObjectType(DateTimeInterface::class), new StringType, new IntegerType, new FloatType); + } + + /** + * Get the cast type without its encryption prefix and arguments. + */ + private function parseCast(string $cast): string + { + $lowercase = strtolower(trim($cast)); + + // Hypervel: as getCastType() does, a cast other than a class or a date or decimal cast with arguments matches + // in lowercase, so ENCRYPTED:ARRAY is encrypted:array. A date or decimal cast with arguments matches only as written. + if (! $this->reflectionProvider->hasClass(explode(':', $cast)[0]) + && preg_match('/^(?:date|datetime|immutable_date|immutable_datetime|decimal):/', $lowercase) !== 1) { + $cast = $lowercase; + } + + foreach (explode(':', $cast) as $part) { + // If the cast is prefixed with `encrypted:` we need to skip to the next + if ($part === 'encrypted') { + continue; + } + + return $part; + } + + return $cast; + } + + /** + * Get the arguments of a cast. + * + * @return list + */ + private function parseCastArguments(string $cast): array + { + if (! str_contains($cast, ':')) { + return []; + } + + return explode(',', explode(':', $cast, 2)[1]); + } + + /** + * Determine whether the model casts the property, including with a cast that cannot be determined. + */ + public function hasCastForProperty(ClassReflection $modelClassReflection, string $propertyName): bool + { + $modelCasts = $this->getModelCasts($modelClassReflection); + + return array_key_exists($propertyName, $modelCasts['casts']) || ! $modelCasts['complete']; + } + + /** + * Determine whether the model's casts name the property, which makes it an attribute even without a column. + */ + public function hasCastEntry(ClassReflection $modelClassReflection, string $propertyName): bool + { + return array_key_exists($propertyName, $this->getModelCasts($modelClassReflection)['casts']); + } + + /** + * Get the model's cast for the property, or null when it has none or its cast cannot be determined. + */ + public function getCastForProperty(ClassReflection $modelClassReflection, string $propertyName): ?string + { + return $this->getModelCasts($modelClassReflection)['casts'][$propertyName] ?? null; + } + + /** + * Get the class a cast names, as HasAttributes::isClassCastable() reads it, or null for a primitive cast type. + * + * Class names are case-insensitive, so a primitive type such as datetime would otherwise name DateTime. + */ + public function getCastClass(string $cast): ?ClassReflection + { + $castType = explode(':', $cast, 2)[0]; + + $this->primitiveCastTypes ??= (new ReflectionProperty(Model::class, 'primitiveCastTypes'))->getValue(); + + return ! in_array($castType, $this->primitiveCastTypes, true) && $this->reflectionProvider->hasClass($castType) + ? $this->reflectionProvider->getClass($castType) + : null; + } + + /** + * Get the caster whose get() and set() methods type a class cast, which run even for null values. + * + * Returns null for primitive, enum and built-in object casts, which are typed here directly, and for a + * Castable whose caster cannot be determined. + */ + public function getCaster(string $cast): ?ClassReflection + { + $castType = $this->parseCast($cast); + + if ( + in_array($castType, [AsArrayObject::class, AsCollection::class, AsEncryptedArrayObject::class, AsEncryptedCollection::class, AsStringable::class], true) + || ! $this->reflectionProvider->hasClass($castType) + ) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($castType); + + if ($classReflection->isEnum()) { + return null; + } + + $caster = $this->resolveCaster($classReflection, $this->parseCastArguments($cast)); + + return $caster !== null && ($caster->is(CastsAttributes::class) || $caster->is(CastsInboundAttributes::class)) + ? $caster + : null; + } + + /** + * Determine whether a cast reads an empty string as null, as the JSON casts do through HasAttributes::fromJson(). + */ + public function readsEmptyStringAsNull(string $cast): bool + { + return in_array(strtolower(trim($cast)), ['array', 'json', 'json:unicode', 'object'], true); + } + + /** + * Get the model's casts, including those its casts() method returns. + * + * A null cast is present but cannot be determined. Unless the casts are complete, any other + * property may have such a cast too. + * + * @return array{casts: array, complete: bool} + * + * @throws ShouldNotHappenException + */ + private function getModelCasts(ClassReflection $modelClassReflection): array + { + $className = $modelClassReflection->getName(); + + if (array_key_exists($className, $this->modelCasts)) { + return $this->modelCasts[$className]; + } + + try { + $modelInstance = $this->modelHelper->getInstance($modelClassReflection); + } catch (ReflectionException) { + throw new ShouldNotHappenException; + } + + // The instance skips the constructor, so these are the $casts property's casts, without casts() + // and without the normalization of [$cast, ...$arguments] that initializeHasAttributes() runs. + $propertyCasts = array_map( + fn (mixed $cast): ?string => $this->getCastFromType(ConstantTypeHelper::getTypeFromValue($cast)), + $modelInstance->getCasts(), + ); + $castsMethod = $modelClassReflection->getNativeMethod('casts'); + + if ($castsMethod->getDeclaringClass()->getName() === Model::class) { + return $this->modelCasts[$className] = ['casts' => $propertyCasts, 'complete' => true]; + } + + $declaredCasts = $this->getCastsFromType($castsMethod->getVariants()[0]->getReturnType()); + $returnedType = $this->parseModelCastsMethod ? $this->parseCastsMethod($modelClassReflection, $castsMethod) : null; + $returnedCasts = $returnedType === null ? null : $this->getCastsFromType($returnedType); + $methodCasts = $returnedCasts ?? $declaredCasts; + + if ($returnedCasts !== null && $declaredCasts !== null) { + // A cast the method body builds at runtime may still be declared in its return type. + foreach ($returnedCasts['casts'] as $attribute => $cast) { + $methodCasts['casts'][$attribute] = $cast ?? $declaredCasts['casts'][$attribute] ?? null; + } + } + + // Unknown method casts can replace the property's casts, but its keys still name attributes. + return $this->modelCasts[$className] = $methodCasts !== null && $methodCasts['closed'] + ? ['casts' => array_merge($propertyCasts, $methodCasts['casts']), 'complete' => true] + : ['casts' => array_merge(array_fill_keys(array_keys($propertyCasts), null), $methodCasts['casts'] ?? []), 'complete' => false]; + } + + /** + * Get the casts a casts() return type sets, with null for those that cannot be determined, and + * whether it sets no others; or null when the type is not a single array shape. + * + * @return null|array{casts: array, closed: bool} + */ + private function getCastsFromType(Type $type): ?array + { + $arrays = $type->getConstantArrays(); + + if (count($arrays) !== 1 || ! $type->isConstantArray()->yes()) { + return null; + } + + $casts = []; + + foreach ($arrays[0]->getKeyTypes() as $index => $keyType) { + // An optional cast may leave the $casts property's cast, or none, in place. + $casts[(string) $keyType->getValue()] = $arrays[0]->isOptionalKey($index) + ? null + : $this->getCastFromType($arrays[0]->getValueTypes()[$index]); + } + + // Outside bleeding edge, PHPStan leaves the sealedness of shapes without "..." undecided, + // though it treats their missing keys as absent. + return ['casts' => $casts, 'closed' => ! $arrays[0]->isUnsealed()->yes()]; + } + + /** + * Get the cast a casts() value defines, with array definitions normalized as Eloquent does, or null + * when the value is not a single constant definition. + */ + private function getCastFromType(Type $type): ?string + { + $strings = $type->getConstantStrings(); + + if (count($strings) === 1) { + return $strings[0]->getValue(); + } + + $arrays = $type->getConstantArrays(); + + if (count($arrays) !== 1 || ! $type->isConstantArray()->yes() || ! $arrays[0]->isList()->yes()) { + return null; + } + + $definition = []; + + foreach ($arrays[0]->getValueTypes() as $index => $valueType) { + $values = $valueType->getConstantScalarValues(); + + if (count($values) !== 1 || $arrays[0]->isOptionalKey($index)) { + return null; + } + + $definition[] = $values[0]; + } + + // As HasAttributes::ensureCastsAreStringValues() normalizes [$cast, ...$arguments]. + $cast = array_shift($definition); + + if (! is_string($cast)) { + return null; + } + + return $definition === [] ? $cast : $cast . ':' . implode(',', $definition); + } + + /** + * Get the type of the array an overridden casts() method returns, or null when its body has no + * single unconditional array return to read. + */ + private function parseCastsMethod(ClassReflection $modelClassReflection, ExtendedMethodReflection $castsMethod): ?Type + { + // The class using a trait declares its methods, which is also what self:: refers to in them. + $declaringClass = $castsMethod->getDeclaringClass(); + // The native method carries the file and line of a trait's method. + $nativeMethod = $declaringClass->getNativeReflection()->getMethod('casts'); + $fileName = (string) $nativeMethod->getFileName(); + $nodeFinder = new NodeFinder; + + /** @var ClassMethod $methodNode */ + $methodNode = $nodeFinder->findFirst( + $this->parser->parseFile($fileName), + static fn (Node $node): bool => $node instanceof ClassMethod + && $node->name->toLowerString() === 'casts' + && $node->getStartLine() === $nativeMethod->getStartLine(), + ); + $statements = $methodNode->stmts ?? []; + + // Returns inside nested closures and classes belong to them. + $nestedReturns = $nodeFinder->findInstanceOf( + $nodeFinder->find($statements, static fn (Node $node): bool => $node instanceof FunctionLike || $node instanceof ClassLike), + Return_::class, + ); + $returns = array_filter( + $nodeFinder->findInstanceOf($statements, Return_::class), + static fn (Return_ $return): bool => ! in_array($return, $nestedReturns, true), + ); + $return = array_find($statements, static fn (Node $statement): bool => $statement instanceof Return_); + + if (count($returns) !== 1 || ! $return instanceof Return_ || ! $return->expr instanceof Array_) { + return null; + } + + $scope = $this->scopeFactory->create(ScopeContext::create($fileName))->enterClass($declaringClass); + + $this->trackReferencedSources($modelClassReflection, $return->expr, $scope); + + return $scope->getType($return->expr); + } + + /** + * Track the files declaring the constants and static methods the returned casts use. + * + * Dependents of the model are otherwise analyzed again only when the model's own declarations change. + */ + private function trackReferencedSources(ClassReflection $modelClassReflection, Array_ $casts, Scope $scope): void + { + $references = (new NodeFinder)->find( + $casts, + static fn (Node $node): bool => ($node instanceof ClassConstFetch || $node instanceof StaticCall) + && $node->class instanceof Name + && $node->name instanceof Identifier, + ); + + foreach ($references as $reference) { + /** @var ClassConstFetch|StaticCall $reference */ + $className = $scope->resolveName($reference->class); + $name = $reference->name->toString(); + + if (! $this->reflectionProvider->hasClass($className)) { + continue; + } + + $class = $this->reflectionProvider->getClass($className); + + // A class below the declaring one can override the member. + $files = match (true) { + $reference instanceof StaticCall => $class->hasNativeMethod($name) + ? SourceFiles::forClass($class, $class->getNativeMethod($name)->getDeclaringClass()->getParentClass()?->getName()) + : [], + $name === 'class' => [], + default => $class->hasConstant($name) ? SourceFiles::forConstant($this->reflectionProvider, $class, $name) : [], + }; + + foreach ($files as $file) { + $this->declarationDependencyTracker->trackFileDependency($modelClassReflection, $file); + } + } + } +} diff --git a/src/phpstan/src/Properties/ModelHelper.php b/src/phpstan/src/Properties/ModelHelper.php new file mode 100644 index 0000000000..2e3517a8f8 --- /dev/null +++ b/src/phpstan/src/Properties/ModelHelper.php @@ -0,0 +1,102 @@ + */ + private array $instances = []; + + /** + * Create a new model helper instance. + */ + public function __construct( + private DeclarationDependencyTracker $declarationDependencyTracker, + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get the model's instance, created without running its constructor and memoized per class, so callers must + * treat it as read-only. + * + * What the instance reads, such as its connection, table, key and casts, comes from property defaults and + * method bodies that are not part of the model's declarations, so their files become dependencies of the + * files depending on the model. + * + * @throws ReflectionException + */ + public function getInstance(ClassReflection $classReflection): Model + { + $className = $classReflection->getName(); + + if (isset($this->instances[$className])) { + return $this->instances[$className]; + } + + $model = self::newInstanceWithoutConstructor($classReflection); + + foreach (SourceFiles::forInstance($this->reflectionProvider, $classReflection, Model::class) as $file) { + $this->declarationDependencyTracker->trackFileDependency($classReflection, $file); + } + + return $this->instances[$className] = $model; + } + + /** + * Get the resolver that resolveRelationUsing() registered at boot for the model's relation, without running it. + * + * @throws ReflectionException when the model cannot be instantiated, so its relations are unknown + */ + public function getRelationResolver(ClassReflection $classReflection, string $name): ?Closure + { + $this->runtimeInputs->track($classReflection); + + return $this->getInstance($classReflection)->relationResolver($classReflection->getName(), $name); + } + + /** + * Create a model instance without running its constructor. + * + * @throws ReflectionException + */ + public static function newInstanceWithoutConstructor(ClassReflection $classReflection): Model + { + try { + /** @var Model $model */ + $model = $classReflection->getNativeReflection()->newInstanceWithoutConstructor(); + } catch (Error $error) { + throw new ReflectionException($error->getMessage(), 0, $error); + } + + // The trait initializers run before the class attributes, as in the constructor. Unique + // string ids decide the key type and incrementing, so they must not depend on call order. + $model->initializeHasTimestamps(); + + if (method_exists($model, 'initializeHasUniqueStringIds')) { + $model->initializeHasUniqueStringIds(); + } + + $model->initializeModelAttributes(); + + return $model; + } +} diff --git a/src/phpstan/src/Properties/ModelProperty.php b/src/phpstan/src/Properties/ModelProperty.php new file mode 100644 index 0000000000..c145a44aea --- /dev/null +++ b/src/phpstan/src/Properties/ModelProperty.php @@ -0,0 +1,132 @@ +declaringClass; + } + + /** + * Determine whether the property is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the property is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the property is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Determine whether the property is readable. + */ + public function isReadable(): bool + { + return true; + } + + /** + * Determine whether the property is writable. + */ + public function isWritable(): bool + { + return $this->writeable; + } + + /** + * Get the property's doc comment. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Get the type the property reads as. + */ + public function getReadableType(): Type + { + return $this->readableType; + } + + /** + * Get the type the property accepts. + */ + public function getWritableType(): Type + { + return $this->writableType; + } + + /** + * Determine whether an assignment narrows the property's type. + */ + public function canChangeTypeAfterAssignment(): bool + { + // Unlike upstream, a raw attribute narrows: reading it returns the assigned value. + return $this->canChangeTypeAfterAssignment; + } + + /** + * Determine whether the property is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Get the property's deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return null; + } + + /** + * Determine whether the property is internal. + */ + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } +} diff --git a/src/phpstan/src/Properties/ModelPropertyExtension.php b/src/phpstan/src/Properties/ModelPropertyExtension.php new file mode 100644 index 0000000000..90ea43edc6 --- /dev/null +++ b/src/phpstan/src/Properties/ModelPropertyExtension.php @@ -0,0 +1,54 @@ +modelPropertyHelper->hasAccessor($classReflection, $propertyName)) { + return false; + } + + $hasProperty = $this->modelPropertyHelper->hasDatabaseProperty($classReflection, $propertyName); + + return $hasProperty->yes() + || ($hasProperty->maybe() + && ! $classReflection->isAbstract() + && ! $this->modelRelationsExtension->hasProperty($classReflection, $propertyName)); + } + + /** + * Get the database property. + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->modelPropertyHelper->getDatabaseProperty($classReflection, $propertyName); + } +} diff --git a/src/phpstan/src/Properties/ModelPropertyHelper.php b/src/phpstan/src/Properties/ModelPropertyHelper.php new file mode 100644 index 0000000000..d39f376816 --- /dev/null +++ b/src/phpstan/src/Properties/ModelPropertyHelper.php @@ -0,0 +1,718 @@ + */ + private array $connections = []; + + private bool $migrationsLoaded = false; + + /** @var array the PHP files besides the migrations that the schema facts were read from, except tables' own sources */ + private array $schemaSources = []; + + /** @var null|array the config sources of the schema facts, each mapped to whether it is a directory */ + private ?array $schemaConfigSources = null; + + /** @var null|array the sources of every booted config value, each mapped to whether it is a directory */ + private ?array $configSources = null; + + /** @var array the connection names of models, by class */ + private array $connectionNames = []; + + /** @var array the declared return types of Attribute accessor methods, null when there is none */ + private array $attributeTypes = []; + + /** @var array> the column names by fetched attribute name, per table and ATTR_CASE */ + private array $fetchedNames = []; + + /** + * Create a new model property helper instance. + */ + public function __construct( + private TypeStringResolver $stringResolver, + private MigrationHelper $migrationHelper, + private SquashedMigrationHelper $squashedMigrationHelper, + private ModelCastHelper $modelCastHelper, + private ModelHelper $modelHelper, + private MigrationCache $migrationCache, + private DeclarationDependencyTracker $declarationDependencyTracker, + private ReflectionProvider $reflectionProvider, + private ConnectionConfigResolver $connectionConfigResolver, + private DataTypeToPhpTypeConverter $converter, + private ConfigSourceResolver $configSourceResolver, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Determine if a property name is accepted for a model type, as any name is for an abstract model. + * + * An abstract model, including Model itself and template bounds, stands for its subclasses, so any + * name may be one of their attributes. ModelPropertyExtension does not claim these names. + */ + public function acceptsPropertyName(Type $modelType, string $propertyName): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity( + TypeUtils::flattenTypes($modelType), + static function (Type $type) use ($propertyName): TrinaryLogic { + $classReflections = $type->getObjectClassReflections(); + $isAbstractModel = static fn (ClassReflection $classReflection): bool => $classReflection->isAbstract() && $classReflection->is(Model::class); + + return $classReflections !== [] && array_all($classReflections, $isAbstractModel) + ? TrinaryLogic::createYes() + : $type->hasInstanceProperty($propertyName); + }, + ); + } + + /** + * Determine if the model has a database property, or maybe has one when its schema facts are unknown. + */ + public function hasDatabaseProperty(ClassReflection $classReflection, string $propertyName): TrinaryLogic + { + if (! $classReflection->is(Model::class)) { + return TrinaryLogic::createNo(); + } + + if (ReflectionHelper::hasPropertyTag($classReflection, $propertyName)) { + return TrinaryLogic::createNo(); + } + + // Unlike upstream, a model without readable schema facts is not claimed to lack the property: + // an abstract model stands for its subclasses, and an uninstantiable one has no known table. + if ($classReflection->isAbstract()) { + return TrinaryLogic::createMaybe(); + } + + try { + $modelInstance = $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + return TrinaryLogic::createMaybe(); + } + + // HasAttributes::hasAttribute() accepts a cast key without a column, such as a class cast built from other columns. + if ($propertyName === $modelInstance->getKeyName() || $this->modelCastHelper->hasCastEntry($classReflection, $propertyName)) { + return TrinaryLogic::createYes(); + } + + $lookup = $this->findTable($classReflection, $modelInstance); + + // Without established facts, a property is not claimed to be missing. + return $lookup === null ? TrinaryLogic::createMaybe() : $this->hasColumn($lookup, $propertyName); + } + + /** + * Determine if a table on the model's connection has a column, or whether it is unknown. + * + * Unlike upstream, the table is looked up on the model's connection rather than by name alone, + * and a table missing from the facts is not claimed, since a query can name a table by an alias. + */ + public function hasTableColumn(ClassReflection $classReflection, string $table, string $column): bool + { + try { + $modelInstance = $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + return true; + } + + $lookup = $this->findTable($classReflection, $modelInstance, $table); + + return $lookup === null || $lookup['table'] === null || ! $this->hasColumn($lookup, $column)->no(); + } + + /** + * Get the schema dumps of the model's connection that could not be read. + * + * @return list + */ + public function getUnusableDumps(ClassReflection $classReflection): array + { + if (! $classReflection->is(Model::class)) { + return []; + } + + try { + $modelInstance = $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + return []; + } + + return $this->findConnection($classReflection, $modelInstance)['schema']->unusableDumps ?? []; + } + + /** + * Get the names of the attributes a model fetches from its table, or null when they are not all known. + * + * @return null|list + */ + public function getDatabasePropertyNames(ClassReflection $classReflection): ?array + { + try { + $model = $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + return null; + } + + $lookup = $this->findTable($classReflection, $model); + $table = $lookup['table'] ?? null; + + if ($table === null || ! $table->complete || $lookup['config']->customized) { + return null; + } + + $names = []; + + foreach ($lookup['config']->fetchOptions as $options) { + $names = [...$names, ...array_keys($this->getFetchedNames($table, $options))]; + } + + return array_values(array_unique($names)); + } + + /** + * Get the database property of a model, which also types the directions its accessors and mutators leave to Eloquent. + */ + public function getDatabaseProperty(ClassReflection $classReflection, string $propertyName): ModelProperty + { + try { + $modelInstance = $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + // Reachable through an accessor on an abstract or uninstantiable model, whose attributes are unknown. + return new ModelProperty($classReflection, new MixedType, new MixedType); + } + + $lookup = $this->findTable($classReflection, $modelInstance); + $table = $lookup['table'] ?? null; + $hasCast = $this->modelCastHelper->hasCastForProperty($classReflection, $propertyName); + $cast = $this->modelCastHelper->getCastForProperty($classReflection, $propertyName); + + if ($propertyName === $modelInstance->getKeyName() && ! isset($table->columns[$propertyName])) { + $keyType = $this->stringResolver->resolve($modelInstance->getKeyType()); + [$readableType, $writableType, $readNullable, $writeNullable] = [$keyType, $keyType, false, false]; + } else { + // A virtual attribute behind an accessor has no column, so its raw value is whatever was assigned, + // or null when nothing was. + [$readableType, $writableType, $readNullable, $writeNullable] = $lookup === null || $table === null || $this->hasColumn($lookup, $propertyName)->no() + ? [new MixedType, new MixedType, true, true] + : $this->getColumnTypes($lookup, $table, $propertyName, $cast !== null && $this->modelCastHelper->readsEmptyStringAsNull($cast)); + } + + $date = $this->hasDate($classReflection, $modelInstance, $propertyName); + + if ($hasCast && $cast === null) { + // A cast that cannot be determined may turn the attribute into anything. + $readableType = new MixedType; + $writableType = $date ? $this->modelCastHelper->getDateWriteableType() : new MixedType; + } elseif ($date) { + $readableType = $cast !== null + ? $this->modelCastHelper->getReadableType($cast, $readableType) + : $this->modelCastHelper->getDateType(); + // Eloquent runs fromDateTime() on date columns before applying any cast, + // so the cast's setter type does not apply to assignments. + $writableType = $this->modelCastHelper->getDateWriteableType(); + } elseif ($cast !== null) { + $readableType = $this->modelCastHelper->getReadableType($cast, $readableType); + $writableType = $this->modelCastHelper->getWriteableType($cast, $writableType); + } + + if (($date || $cast !== null) && ($readNullable || $writeNullable)) { + // A caster's get() and set() also handle null, so their declarations decide it. + $caster = $cast === null ? null : $this->modelCastHelper->getCaster($cast); + + if ($readNullable && ! $caster?->is(CastsAttributes::class)) { + $readableType = TypeCombinator::addNull($readableType); + } + + if ($writeNullable && ($date || $caster === null)) { + $writableType = TypeCombinator::addNull($writableType); + } + } + + return new ModelProperty( + $classReflection, + $readableType, + $writableType, + canChangeTypeAfterAssignment: ! $date && ! $hasCast, + ); + } + + /** + * Get the types a column attribute reads as and accepts, with whether a read may be null and whether a write may be. + * + * Raw types include null and the connection's fetch options; the attribute reads the column + * each option set's ATTR_CASE fetches under that name, or null when none does. + * + * @param array{config: ConnectionConfig, schema: SchemaConnection, table: null|SchemaTable} $lookup + * + * @return array{Type, Type, bool, bool} + */ + private function getColumnTypes(array $lookup, SchemaTable $table, string $propertyName, bool $castReadsEmptyStringAsNull): array + { + $config = $lookup['config']; + + if ($config->customized) { + return [new MixedType, new MixedType, true, true]; + } + + $schema = $lookup['schema']; + $searchPath = $table->searchPath ?? $config->searchPath; + $writeColumn = $table->columns[$propertyName] ?? null; + $readableTypes = []; + $readNullable = false; + + foreach ($config->fetchOptions as $options) { + $name = $this->getFetchedNames($table, $options)[$propertyName] ?? null; + $column = $name === null ? null : $table->columns[$name]; + + if ($column === null) { + $readableTypes[] = $table->complete ? new NullType : new MixedType; + $readNullable = true; + + continue; + } + + $readableType = $column->type === null + ? new MixedType + : $this->converter->convert($schema->driver, $column->type, $column->options, $schema, $searchPath)['readable']; + // The column decides null, for an unknown or unmapped type too, before the fetch options apply. + $readableType = $column->nullable ? TypeCombinator::addNull($readableType) : TypeCombinator::remove($readableType, new NullType); + $readableType = $this->converter->applyFetchOptions($readableType, $schema->driver, [$options]); + $readableTypes[] = $readableType; + // NULL_TO_STRING fetches a null column as an empty string, which such a cast still reads as null. + $readNullable = $readNullable || ! $readableType->isNull()->no() || ($castReadsEmptyStringAsNull && $column->nullable); + } + + if ($writeColumn?->type === null) { + $writableType = new MixedType; + } else { + $writableType = $this->converter->convert($schema->driver, $writeColumn->type, $writeColumn->options, $schema, $searchPath)['writable']; + $writableType = $writeColumn->nullable ? TypeCombinator::addNull($writableType) : $writableType; + } + + // Without a known column, the attribute may hold null. + return [TypeCombinator::union(...$readableTypes), $writableType, $readNullable, $writeColumn->nullable ?? true]; + } + + /** + * Determine whether a found table has a column, either as written or as an option set fetches it, + * or maybe has it when the table's columns are not all known. + * + * @param array{config: ConnectionConfig, schema: SchemaConnection, table: null|SchemaTable} $lookup + */ + private function hasColumn(array $lookup, string $column): TrinaryLogic + { + $table = $lookup['table']; + + if ($table === null) { + return TrinaryLogic::createNo(); + } + + if (isset($table->columns[$column])) { + return TrinaryLogic::createYes(); + } + + foreach ($lookup['config']->fetchOptions as $options) { + if (isset($this->getFetchedNames($table, $options)[$column])) { + return TrinaryLogic::createYes(); + } + } + + return $table->complete ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(); + } + + /** + * Get a table's column names by the attribute names an option set's ATTR_CASE fetches them as. + * + * @param array $options + * + * @return array + */ + private function getFetchedNames(SchemaTable $table, array $options): array + { + $case = $options[PDO::ATTR_CASE] ?? PDO::CASE_NATURAL; + $key = spl_object_id($table) . ':' . $case; + + if (isset($this->fetchedNames[$key])) { + return $this->fetchedNames[$key]; + } + + $names = array_keys($table->columns); + + return $this->fetchedNames[$key] = array_combine( + array_map(static fn (string $name): string => match ($case) { + PDO::CASE_LOWER => strtolower($name), + PDO::CASE_UPPER => strtoupper($name), + default => $name, + }, $names), + $names, + ); + } + + /** + * Find the configuration and schema facts of a model's connection, or return null when they are unknown. + * + * @return null|array{config: ConnectionConfig, schema: SchemaConnection} + */ + private function findConnection(ClassReflection $classReflection, Model $model): ?array + { + if (! $this->migrationsLoaded()) { + $this->loadMigrations(); + } + + $connectionName = $this->getConnectionName($classReflection, $model) ?? $this->connectionConfigResolver->getDefaultConnectionName(); + $config = $connectionName === null ? null : $this->connectionConfigResolver->connection($connectionName); + $schema = $config === null ? null : $this->connections[$config->owner] ?? null; + + return $config === null || $schema === null ? null : ['config' => $config, 'schema' => $schema]; + } + + /** + * Get the name of the model's connection, or null for the default one. + * + * An overriding getConnectionName() can read any config value, so every booted config source + * becomes a dependency of the files depending on the model. + */ + private function getConnectionName(ClassReflection $classReflection, Model $model): ?string + { + $className = $classReflection->getName(); + + if (array_key_exists($className, $this->connectionNames)) { + return $this->connectionNames[$className]; + } + + if ($classReflection->getNativeMethod('getConnectionName')->getDeclaringClass()->getName() !== Model::class) { + $this->trackConfigSources( + $classReflection, + $this->configSources ??= $this->configSourceResolver->getAllSources($this->configSourceResolver->getBootedConfigDirectories()), + ); + } + + return $this->connectionNames[$className] = $model->getConnectionName(); + } + + /** + * Find a model's table in the schema facts of its connection, or return null when the facts are unknown. + * + * A null table means the table does not exist on a connection whose relations are all known. A found + * table's sources become dependencies of the model, since its column facts were read from them. + * + * @return null|array{config: ConnectionConfig, schema: SchemaConnection, table: null|SchemaTable} + */ + private function findTable(ClassReflection $classReflection, Model $model, ?string $table = null): ?array + { + $connection = $this->findConnection($classReflection, $model); + + if ($connection === null) { + return null; + } + + ['config' => $config, 'schema' => $schema] = $connection; + [$tableSchema, $name] = $config->physicalTable($table ?? $model->getTable()); + $key = $schema->findTableKey($tableSchema, $name, $config->defaultSchema, $config->searchPath); + + if ($key === false || ($key === null && ! $schema->complete)) { + return null; + } + + $tableFacts = $key === null ? null : $schema->tables[$key]; + + foreach (array_keys($tableFacts->sources ?? []) as $source) { + $this->declarationDependencyTracker->trackFileDependency($classReflection, $this->runtimeInputs->sourcePath($source)); + } + + return ['config' => $config, 'schema' => $schema, 'table' => $tableFacts]; + } + + /** + * Determine if an accessor or mutator handles the property, as a get{Name}Attribute() or set{Name}Attribute() method or an Attribute method. + * + * Unlike upstream, public Attribute methods and those without generics count, as Eloquent uses them too. + */ + public function hasAccessor(ClassReflection $classReflection, string $propertyName): bool + { + return $classReflection->is(Model::class) + && ($this->getAttributeType($classReflection, $propertyName) !== null + || $classReflection->hasNativeMethod('get' . Str::studly($propertyName) . 'Attribute') + || $classReflection->hasNativeMethod('set' . Str::studly($propertyName) . 'Attribute')); + } + + /** + * Get the property an accessor or mutator provides. + * + * Each direction follows Eloquent's precedence: the get{Name}Attribute() or set{Name}Attribute() method, + * then the Attribute callback, then the database property with its casts. Unlike upstream, a direction + * without a callback is not refused, since Eloquent then reads or writes the attribute itself. + */ + public function getAccessor(ClassReflection $classReflection, string $propertyName): ModelProperty + { + $readableType = $this->getGetMutatorType($classReflection, $propertyName) + ?? $this->getAttributeGetMutatorType($classReflection, $propertyName); + $writableType = $this->getSetMutatorType($classReflection, $propertyName) + ?? $this->getAttributeSetMutatorType($classReflection, $propertyName); + + if ($readableType !== null && $writableType !== null) { + return new ModelProperty($classReflection, $readableType, $writableType); + } + + $databaseProperty = $this->getDatabaseProperty($classReflection, $propertyName); + + return new ModelProperty( + $classReflection, + $readableType ?? $databaseProperty->getReadableType(), + $writableType ?? $databaseProperty->getWritableType(), + canChangeTypeAfterAssignment: $readableType === null && $writableType === null + && $databaseProperty->canChangeTypeAfterAssignment(), + ); + } + + /** + * Get the type a get{Name}Attribute() accessor returns, or null when there is none. + */ + public function getGetMutatorType(ClassReflection $classReflection, string $propertyName): ?Type + { + $method = 'get' . Str::studly($propertyName) . 'Attribute'; + + return $classReflection->hasNativeMethod($method) + ? $classReflection->getNativeMethod($method)->getVariants()[0]->getReturnType() + : null; + } + + /** + * Get the type an Attribute accessor's get callback returns, or null when it has none. + */ + public function getAttributeGetMutatorType(ClassReflection $classReflection, string $propertyName): ?Type + { + $type = $this->getAttributeType($classReflection, $propertyName)?->getTemplateType(Attribute::class, 'TGet'); + + return $type instanceof NeverType ? null : $type; + } + + /** + * Get the type a set{Name}Attribute() mutator accepts, or null when there is none. + */ + private function getSetMutatorType(ClassReflection $classReflection, string $propertyName): ?Type + { + $method = 'set' . Str::studly($propertyName) . 'Attribute'; + + if (! $classReflection->hasNativeMethod($method)) { + return null; + } + + return ($classReflection->getNativeMethod($method)->getVariants()[0]->getParameters()[0] ?? null)?->getType() + ?? new MixedType; + } + + /** + * Get the type an Attribute mutator's set callback accepts, or null when it has none. + */ + private function getAttributeSetMutatorType(ClassReflection $classReflection, string $propertyName): ?Type + { + $type = $this->getAttributeType($classReflection, $propertyName)?->getTemplateType(Attribute::class, 'TSet'); + + return $type instanceof NeverType ? null : $type; + } + + /** + * Get the declared return type of the property's Attribute method, or null when Eloquent would not use one. + * + * Like HasAttributes::hasAttributeMutator(), the method's native return type must name Attribute itself. + * Private methods are excluded, since Eloquent cannot call them. + */ + private function getAttributeType(ClassReflection $classReflection, string $propertyName): ?Type + { + $cacheKey = $classReflection->getCacheKey() . '-' . $propertyName; + + if (array_key_exists($cacheKey, $this->attributeTypes)) { + return $this->attributeTypes[$cacheKey]; + } + + $method = Str::camel($propertyName); + + if (! $classReflection->hasNativeMethod($method) || $classReflection->getNativeMethod($method)->isPrivate()) { + return $this->attributeTypes[$cacheKey] = null; + } + + $returnType = $classReflection->getNativeReflection()->getMethod($method)->getReturnType(); + + return $this->attributeTypes[$cacheKey] = $returnType instanceof ReflectionNamedType && $returnType->getName() === Attribute::class + ? $classReflection->getNativeMethod($method)->getVariants()[0]->getReturnType() + : null; + } + + /** + * Determine whether the schema facts are loaded. + */ + private function migrationsLoaded(): bool + { + return $this->migrationsLoaded; + } + + /** + * Load the schema facts from the schema dumps and migrations. + */ + private function loadMigrations(): void + { + $this->migrationsLoaded = true; + + $migrationFiles = $this->migrationHelper->getMigrationFiles(); + $schemaFiles = $this->squashedMigrationHelper->getSchemaFiles(); + + ['connections' => $this->connections, 'sources' => $this->schemaSources] = $this->migrationCache->remember( + $migrationFiles, + $schemaFiles, + $this->connectionConfigResolver->getSchemaConfiguration(), + function (): array { + // Migrations apply on top of the schema dumps, as migrate loads a dump first. + $aggregator = $this->migrationHelper->aggregate($this->squashedMigrationHelper->initializeConnections()); + + return ['connections' => $aggregator->connections, 'sources' => $aggregator->sources]; + }, + ); + + // Every file using a model depends on Model, so the dependency is declared once, on Model itself. + // The config keys the facts read are known only now. + $this->trackSchemaDependency($this->reflectionProvider->getClass(Model::class)); + } + + /** + * Track the schema inputs as dependencies of the class. + * + * What the class declares depends on the database schema, which comes from + * the migrations, the schema dumps, the config they read and the PHP files + * naming their tables and connections. Each file depending on the class is + * analyzed again when one of them is created, changed or deleted. The files + * a table's columns were read from are tracked on the models reading it. + */ + public function trackSchemaDependency(ClassReflection $classReflection): void + { + if (! $this->migrationsLoaded()) { + $this->loadMigrations(); + } + + // The booted configuration and migrator choose the connections and migration paths. + $this->runtimeInputs->track($classReflection); + + foreach ($this->migrationHelper->getMigrationPaths() as $path => $pattern) { + if ($pattern === null) { + $this->declarationDependencyTracker->trackFileDependency($classReflection, $this->runtimeInputs->sourcePath($path)); + } else { + $this->declarationDependencyTracker->trackDirectoryDependency($classReflection, $this->runtimeInputs->sourceDirectory($path, $pattern), $pattern); + } + } + + foreach ($this->squashedMigrationHelper->getSchemaDirectories() as $directory) { + $this->declarationDependencyTracker->trackDirectoryDependency($classReflection, $this->runtimeInputs->sourceDirectory($directory)); + } + + foreach (array_keys($this->schemaSources) as $source) { + $this->declarationDependencyTracker->trackFileDependency($classReflection, $this->runtimeInputs->sourcePath($source)); + } + + $this->trackConfigSources($classReflection, $this->getSchemaConfigSources()); + } + + /** + * Track config sources, each mapped to whether it is a directory of config files, as dependencies of the class. + * + * @param array $sources + */ + private function trackConfigSources(ClassReflection $classReflection, array $sources): void + { + foreach ($sources as $path => $directory) { + if ($directory) { + $this->declarationDependencyTracker->trackDirectoryDependency( + $classReflection, + $this->runtimeInputs->sourceDirectory($path, ConfigSourceResolver::CONFIG_FILE_PATTERN), + ConfigSourceResolver::CONFIG_FILE_PATTERN, + ); + } else { + $this->declarationDependencyTracker->trackFileDependency($classReflection, $this->runtimeInputs->sourcePath($path)); + } + } + } + + /** + * Get the paths whose contents can change the config the schema facts read, each mapped to + * whether it is a directory. + * + * The facts read the booted configuration, so its sources are the booted config directories, + * even when the analysis types config from others. + * + * @return array + */ + private function getSchemaConfigSources(): array + { + if ($this->schemaConfigSources !== null) { + return $this->schemaConfigSources; + } + + $sources = []; + $directories = $this->configSourceResolver->getBootedConfigDirectories(); + + foreach ($this->connectionConfigResolver->getConsumedKeys() as $key) { + $sources += $this->configSourceResolver->getKeySources($key, $directories); + } + + return $this->schemaConfigSources = $sources; + } + + /** + * Determine whether the model treats the attribute as a date. + */ + private function hasDate(ClassReflection $classReflection, Model $modelInstance, string $propertyName): bool + { + $dates = $modelInstance->getDates(); + + // In order to support SoftDeletes + // Hypervel: initializeSoftDeletes() adds its datetime cast only when the column has no cast, + // including one an undetermined casts() entry may hold. + if (method_exists($modelInstance, 'getDeletedAtColumn') + && ! $this->modelCastHelper->hasCastForProperty($classReflection, $modelInstance->getDeletedAtColumn())) { + $dates[] = $modelInstance->getDeletedAtColumn(); + } + + return in_array($propertyName, $dates, true); + } +} diff --git a/src/phpstan/src/Properties/ModelRelationsExtension.php b/src/phpstan/src/Properties/ModelRelationsExtension.php new file mode 100644 index 0000000000..335ae1dbca --- /dev/null +++ b/src/phpstan/src/Properties/ModelRelationsExtension.php @@ -0,0 +1,179 @@ + */ + private array $hasPropertyCache = []; + + /** + * Determine whether the property is a relation or a relation count. + */ + public function hasProperty(ClassReflection $classReflection, string $propertyName): bool + { + if (! $classReflection->is(Model::class)) { + return false; + } + + $cacheKey = $classReflection->getCacheKey() . '-' . $propertyName; + + if (array_key_exists($cacheKey, $this->hasPropertyCache)) { + return $this->hasPropertyCache[$cacheKey]; + } + + return $this->hasPropertyCache[$cacheKey] = $this->resolveHasProperty($classReflection, $propertyName); + } + + /** + * Determine whether the property is a relation or a relation count, without the memoization. + */ + private function resolveHasProperty(ClassReflection $classReflection, string $propertyName): bool + { + if (ReflectionHelper::hasPropertyTag($classReflection, $propertyName)) { + return false; + } + + // Unlike upstream, a relation named like a count is a relation, as the model reads it. + if ($this->relationReturnType($classReflection, $propertyName) !== null) { + return true; + } + + if (! str_ends_with($propertyName, '_count')) { + return false; + } + + // Unlike upstream's Str::before(), only the suffix is removed, so stock_count_items_count names stock_count_items(). + $relationName = substr($propertyName, 0, -6); + + return $this->relationReturnType($classReflection, Str::camel($relationName)) !== null + || $this->relationReturnType($classReflection, $relationName) !== null; + } + + /** + * Get the declared return of the relation the model loads by the name, or null when the name is no relation. + * + * A relation method declares it. Unlike upstream, so does the resolver of a relation that + * resolveRelationUsing() registers, unless a native method takes the name. + */ + private function relationReturnType(ClassReflection $classReflection, string $name): ?Type + { + if (! $this->isRelationMethod($classReflection, $name)->no()) { + return $classReflection->getMethod($name, new OutOfClassScope)->getVariants()[0]->getReturnType(); + } + + if ($classReflection->hasNativeMethod($name)) { + return null; + } + + try { + $resolver = $this->modelHelper->getRelationResolver($classReflection, $name); + } catch (ReflectionException) { + return null; + } + + // The resolver is never run. + return $resolver === null ? null : $this->closureTypeFactory->fromClosureObject($resolver)->getReturnType(); + } + + /** + * Determine whether a model method is a relation the model can load: a native method that the base model + * does not declare, callable without arguments, returning a relation. + * + * Unlike upstream, a method without a declared relation return may be a relation of unknown type. + */ + public function isRelationMethod(ClassReflection $classReflection, string $methodName): TrinaryLogic + { + // Model::getAttribute() never treats the base model's own methods as relations. + if (! $classReflection->hasNativeMethod($methodName) + || $this->reflectionProvider->getClass(Model::class)->hasNativeMethod($methodName)) { + return TrinaryLogic::createNo(); + } + + $variant = $classReflection->getNativeMethod($methodName)->getVariants()[0]; + + foreach ($variant->getParameters() as $parameter) { + if (! $parameter->isOptional()) { + return TrinaryLogic::createNo(); + } + } + + return (new ObjectType(Relation::class))->isSuperTypeOf($variant->getReturnType())->result; + } + + /** + * Get the relation's result or count property. + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + $returnType = $this->relationReturnType($classReflection, $propertyName); + + if ($returnType === null) { + return new ModelProperty($classReflection, IntegerRangeType::createAllGreaterThanOrEqualTo(0), new NeverType, false); + } + + // A relation whose declared return may not be a relation, such as an untyped one, loads an unknown result. + if (! (new ObjectType(Relation::class))->isSuperTypeOf($returnType)->yes()) { + return new ModelProperty($classReflection, new MixedType, new NeverType, false); + } + + $relationType = TypeTraverser::map($returnType, static function (Type $type, callable $traverse): Type { + // A relation intersected with an interface yields its result, which the interface does not describe. + if ((new ObjectType(Relation::class))->isSuperTypeOf($type)->yes()) { + return $type->getTemplateType(Relation::class, 'TResult'); + } + + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + return $type; + }); + + $relationType = $this->collectionHelper->replaceCollectionsInType($relationType); + + return new ModelProperty($classReflection, $relationType, new NeverType, false); + } +} diff --git a/src/phpstan/src/Properties/Schema/DataTypeToPhpTypeConverter.php b/src/phpstan/src/Properties/Schema/DataTypeToPhpTypeConverter.php new file mode 100644 index 0000000000..541ef5f389 --- /dev/null +++ b/src/phpstan/src/Properties/Schema/DataTypeToPhpTypeConverter.php @@ -0,0 +1,373 @@ + $options the values a CHECK constraint from Blueprint's enum() allows + * @param list $searchPath the schemas an unqualified PostgreSQL type name resolves through + * + * @return array{readable: Type, writable: Type} + */ + public function convert(string $driver, ColumnType $type, array $options, SchemaConnection $schema, array $searchPath): array + { + if ($options !== []) { + $labels = $this->labels($options); + + return ['readable' => $labels, 'writable' => $labels]; + } + + return match ($driver) { + 'mysql', 'mariadb' => $this->convertMySqlType($type), + 'pgsql' => $this->convertPostgresType($type, $schema, $searchPath), + 'sqlite' => $this->convertSQLiteType($type), + default => $this->unknown(), + }; + } + + /** + * Apply the connection's fetch options to a fetched type, as the union over its distinct option sets. + * + * @param list> $fetchOptions + */ + public function applyFetchOptions(Type $type, string $driver, array $fetchOptions): Type + { + $types = []; + + foreach ($fetchOptions as $options) { + $fetched = $type; + + if (($options[PDO::ATTR_STRINGIFY_FETCHES] ?? false) === true) { + $fetched = $this->stringify($fetched, $driver); + } + + $fetched = match ($options[PDO::ATTR_ORACLE_NULLS] ?? PDO::NULL_NATURAL) { + // remove() also subtracts null from mixed, which removeNull() leaves alone. + PDO::NULL_TO_STRING => $fetched->isNull()->no() + ? $fetched + : TypeCombinator::union(TypeCombinator::remove($fetched, new NullType), new ConstantStringType('')), + PDO::NULL_EMPTY_STRING => $fetched->isSuperTypeOf(new ConstantStringType(''))->no() + ? $fetched + : TypeCombinator::addNull($fetched), + default => $fetched, + }; + + $types[] = $fetched; + } + + return $types === [] ? $type : TypeCombinator::union(...$types); + } + + /** + * Get the types of a MySQL or MariaDB column. + * + * @return array{readable: Type, writable: Type} + */ + private function convertMySqlType(ColumnType $type): array + { + if (in_array($type->name, ['binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob'], true)) { + return ['readable' => new StringType, 'writable' => $this->binaryInput()]; + } + + $unsignedInteger = IntegerRangeType::fromInterval(0, null); + $unsignedBigInteger = TypeCombinator::union($unsignedInteger, $this->numericString()); + + $readable = match ($type->name) { + 'tinyint', 'smallint', 'mediumint', 'int', 'integer' => $type->unsigned ? $unsignedInteger : new IntegerType, + // Values above PHP_INT_MAX fetch as strings. + 'bigint' => $type->unsigned ? $unsignedBigInteger : new IntegerType, + // Unlike upstream's string type, BIT columns fetch as integers. + 'bit' => ($type->modifiers[0] ?? '1') === '64' ? $unsignedBigInteger : $unsignedInteger, + 'bool', 'boolean', 'year' => new IntegerType, + // Unlike upstream's float type, decimals fetch as strings to keep their precision. + 'decimal', 'dec', 'numeric', 'fixed' => $this->numericString(), + 'float', 'double', 'double precision', 'real' => new FloatType, + 'enum' => $this->labels($type->values), + // Unlike upstream's labels, a set holds a comma-joined combination of them. + 'char', 'varchar', 'character', 'character varying', 'nchar', 'nvarchar', 'tinytext', 'text', + 'mediumtext', 'longtext', 'json', 'date', 'datetime', 'timestamp', 'time', 'set', 'uuid', 'inet4', + 'inet6' => new StringType, + default => new MixedType, + }; + + return [ + 'readable' => $readable, + 'writable' => match (true) { + in_array($type->name, ['decimal', 'dec', 'numeric', 'fixed'], true) => $this->decimalInput($this->numericString()), + $this->isBooleanColumn($type) => TypeCombinator::union(new BooleanType, new IntegerType), + default => $readable, + }, + ]; + } + + /** + * Get the types of a PostgreSQL column, resolving user enums and domains. + * + * A domain resolves through the connection's type facts, not the base the parser embedded, + * so a definition the facts hold as uncertain is never bypassed. + * + * @param list $searchPath + * @param array $domains the identity keys of the domains this resolution passed through + * + * @return array{readable: Type, writable: Type} + */ + private function convertPostgresType(ColumnType $type, SchemaConnection $schema, array $searchPath, array $domains = []): array + { + // PDO fetches arrays, including arrays of a domain, as their text representation. + if ($type->isArray()) { + return ['readable' => new StringType, 'writable' => new StringType]; + } + + // pg_catalog is searched first unless the search path names it explicitly. + $schemas = $type->schema !== null + ? [$type->schema] + : (in_array('pg_catalog', $searchPath, true) ? $searchPath : ['pg_catalog', ...$searchPath]); + + // A domain's unqualified base named a type when the domain was created. Unless pg_catalog and one schema are + // searched, in that order, a user type of that name created later in an earlier schema now shadows it. + $shadowable = $domains !== [] && $type->schema === null && ($schemas[0] !== 'pg_catalog' || count($schemas) !== 2); + + // Raw migration SQL can define a type without a schema, in whichever path schema existed first. + $unqualifiedKey = $schema->key(null, $type->name); + $hasUnqualified = $type->schema === null && array_key_exists($unqualifiedKey, $schema->types); + + foreach ($schemas as $candidate) { + if ($candidate === 'pg_catalog' && ($builtIn = $this->convertPostgresBuiltInType($type->name)) !== null) { + return $builtIn; + } + + $key = $schema->key($candidate, $type->name); + + // An uncertain definition ends the search, since the type may exist in this schema. + if (array_key_exists($key, $schema->types)) { + $userType = $schema->types[$key]; + + return $userType === null || $hasUnqualified || $shadowable || isset($domains[$key]) + ? $this->unknown() + : $this->convertPostgresUserType($userType, $schema, $searchPath, $domains + [$key => true]); + } + } + + $unqualified = $hasUnqualified ? $schema->types[$unqualifiedKey] : null; + + return $unqualified === null || $shadowable || isset($domains[$unqualifiedKey]) + ? $this->unknown() + : $this->convertPostgresUserType($unqualified, $schema, $searchPath, $domains + [$unqualifiedKey => true]); + } + + /** + * Get the types of a PostgreSQL enum or domain. + * + * A domain revisited through its own base can only come from migrations that fail, such as dropping a domain + * another depends on without CASCADE and creating it again over that one, so it reads as unknown. + * + * @param list $searchPath + * @param array $domains the identity keys of the domains this resolution passed through + * + * @return array{readable: Type, writable: Type} + */ + private function convertPostgresUserType(Domain|EnumType $userType, SchemaConnection $schema, array $searchPath, array $domains): array + { + if ($userType instanceof EnumType) { + $labels = $this->labels($userType->values); + + return ['readable' => $labels, 'writable' => $labels]; + } + + // A domain's NOT NULL does not stop outer joins from reading null, so only its base type is used. + return $this->convertPostgresType($userType->type, $schema, $searchPath, $domains); + } + + /** + * Get the types of a PostgreSQL built-in type, or null when it is not one. + * + * @return null|array{readable: Type, writable: Type} + */ + private function convertPostgresBuiltInType(string $name): ?array + { + $readable = match (true) { + $name === 'bool' => new BooleanType, + in_array($name, ['int2', 'int4', 'int8'], true) => new IntegerType, + in_array($name, ['float4', 'float8'], true) => new FloatType, + // Unlike upstream's float type, numeric fetches as a string, which can hold NaN and Infinity. + $name === 'numeric' => new StringType, + // Unlike upstream's string type, PDO fetches bytea as a stream. + $name === 'bytea' => new ResourceType, + in_array($name, self::POSTGRES_TEXT_TYPES, true) => new StringType, + default => null, + }; + + return $readable === null ? null : [ + 'readable' => $readable, + 'writable' => match ($name) { + 'numeric' => $this->decimalInput(new StringType), + 'bytea' => $this->binaryInput(), + default => $readable, + }, + ]; + } + + /** + * Get the types of a SQLite column from its declared type name. + * + * The types describe the values the framework writes, since SQLite stores any value in + * a column unless the table is STRICT. + * + * @return array{readable: Type, writable: Type} + */ + private function convertSQLiteType(ColumnType $type): array + { + $name = $type->name; + + // Type names follow SQLite's affinity rules, except those whose framework writes are strings or integers. + $readable = match (true) { + in_array($name, ['date', 'datetime', 'time', 'timestamp'], true) => new StringType, + // Numeric affinity stores a JSON number as an integer or a real, which fetches as a PHP number. + in_array($name, ['json', 'jsonb'], true) => TypeCombinator::union(new FloatType, new IntegerType, new StringType), + in_array($name, ['bool', 'boolean'], true) || str_contains($name, 'int') => new IntegerType, + str_contains($name, 'char') || str_contains($name, 'clob') || str_contains($name, 'text') => new StringType, + str_contains($name, 'blob') => ['readable' => new StringType, 'writable' => $this->binaryInput()], + str_contains($name, 'real') || str_contains($name, 'floa') || str_contains($name, 'doub') => new FloatType, + // Unlike upstream's float type, numeric affinity stores whole numbers as integers. + in_array($name, ['numeric', 'decimal'], true) => [ + 'readable' => TypeCombinator::union(new FloatType, new IntegerType), + 'writable' => $this->decimalInput($this->numericString()), + ], + // Other names, and a column without a type, keep whatever value was written. + default => new MixedType, + }; + + if (is_array($readable)) { + return $readable; + } + + return [ + 'readable' => $readable, + 'writable' => $this->isBooleanColumn($type) ? TypeCombinator::union(new BooleanType, new IntegerType) : $readable, + ]; + } + + /** + * Determine whether an integer column holds a boolean, as Blueprint's boolean() declares it. + */ + private function isBooleanColumn(ColumnType $type): bool + { + return in_array($type->name, ['bool', 'boolean'], true) + || ($type->name === 'tinyint' && $type->modifiers === ['1']); + } + + /** + * Convert a fetched type to the strings ATTR_STRINGIFY_FETCHES returns. + */ + private function stringify(Type $type, string $driver): Type + { + return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($driver): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + return match (true) { + $type->isInteger()->yes() => $this->numericString(), + // MySQL doubles cannot hold NaN or infinity, unlike PostgreSQL and SQLite floats. + $type->isFloat()->yes() => in_array($driver, ['mysql', 'mariadb'], true) ? $this->numericString() : new StringType, + $type->isBoolean()->yes() => TypeCombinator::union(new ConstantStringType('0'), new ConstantStringType('1')), + default => $type, + }; + }); + } + + /** + * Get the literal union of a column's allowed values. + * + * @param list $values + */ + private function labels(array $values): Type + { + return $values === [] + ? new StringType + : TypeCombinator::union(...array_map(static fn (string $value): Type => new ConstantStringType($value), $values)); + } + + /** + * Get the types a binary column accepts: PdoConnection::bindValues() binds a stream as a large object. + */ + private function binaryInput(): Type + { + return TypeCombinator::union(new StringType, new ResourceType); + } + + /** + * Get the types a decimal column accepts besides the string it fetches. + */ + private function decimalInput(Type $string): Type + { + return TypeCombinator::union($string, new IntegerType, new FloatType); + } + + /** + * Get the types of a column whose values are unknown. + * + * @return array{readable: Type, writable: Type} + */ + private function unknown(): array + { + return ['readable' => new MixedType, 'writable' => new MixedType]; + } + + /** + * Get the numeric-string type. + */ + private function numericString(): Type + { + return new IntersectionType([new StringType, new AccessoryNumericStringType]); + } +} diff --git a/src/phpstan/src/Properties/SchemaAggregator.php b/src/phpstan/src/Properties/SchemaAggregator.php new file mode 100644 index 0000000000..51aebc44aa --- /dev/null +++ b/src/phpstan/src/Properties/SchemaAggregator.php @@ -0,0 +1,2799 @@ +, bool, ?string} + * @phpstan-type TableScope array{connection: ConnectionConfig, key: string, before: ?SchemaTable, variable: string} + * @phpstan-type BuilderVariables array{builders: array, escaped: array>} + * @phpstan-type Scope array{class: PhpParser\Node\Stmt\Class_, declaring: ?string, connection: ?ConnectionConfig, table: ?TableScope, builders: array, escaped: array>} + * + * @see https://github.com/psalm/laravel-psalm-plugin/blob/master/src/SchemaAggregator.php + */ +class SchemaAggregator +{ + /** Schema builder methods that change no relations or column types. withoutForeignKeyConstraints() runs a callback that is applied on its own. */ + private const array IGNORED_SCHEMA_METHODS = [ + 'createdatabase', 'defaultstringlength', 'defaulttimeprecision', 'disableforeignkeyconstraints', + 'enableforeignkeyconstraints', 'getcolumn', 'getcolumnlisting', 'getcolumns', 'getcolumntype', 'getconnection', + 'getcurrentschemalisting', 'getcurrentschemaname', 'getforeignkeys', 'getindexes', 'getindexlisting', + 'getpartitions', 'getschemas', 'gettablelisting', 'gettables', 'gettypes', 'getviews', 'hascolumn', + 'hascolumns', 'hasforeignkey', 'hasindex', 'hastable', 'hasview', 'parseschemaandtable', 'qualifyindexname', + 'truncatetables', 'withoutcoveredpartitions', 'withoutforeignkeyconstraints', + ]; + + /** Schema builder methods that change one table, by the position and name of their table parameter. */ + private const array TABLE_SCHEMA_METHODS = [ + 'create' => [0, 'table'], 'createrangepartition' => [1, 'partition'], 'drop' => [0, 'table'], + 'dropcolumns' => [0, 'table'], 'dropifexists' => [0, 'table'], 'table' => [0, 'table'], + 'whentabledoesnthavecolumn' => [0, 'table'], 'whentabledoesnthaveindex' => [0, 'table'], + 'whentablehascolumn' => [0, 'table'], 'whentablehasindex' => [0, 'table'], + ]; + + /** Schema builder methods that change the key type morphs() adds. */ + private const array MORPH_KEY_SCHEMA_METHODS = ['defaultmorphkeytype', 'morphusingulids', 'morphusinguuids']; + + /** Blueprint methods that add one column, with the Blueprint type they add. */ + private const array COLUMN_METHODS = [ + 'bigincrements' => 'biginteger', 'biginteger' => 'biginteger', 'binary' => 'binary', 'boolean' => 'boolean', + 'char' => 'char', 'date' => 'date', 'datetime' => 'datetime', 'datetimetz' => 'datetimetz', + 'decimal' => 'decimal', 'double' => 'double', 'enum' => 'enum', 'float' => 'float', 'foreignid' => 'biginteger', + 'foreignulid' => 'char', 'foreignuuid' => 'uuid', 'geography' => 'geography', 'geometry' => 'geometry', + 'id' => 'biginteger', 'increments' => 'integer', 'integer' => 'integer', 'integerincrements' => 'integer', + 'ipaddress' => 'ipaddress', 'json' => 'json', 'jsonb' => 'jsonb', 'longbinary' => 'longbinary', + 'longtext' => 'longtext', 'macaddress' => 'macaddress', 'mediumbinary' => 'mediumbinary', + 'mediumincrements' => 'mediuminteger', 'mediuminteger' => 'mediuminteger', 'mediumtext' => 'mediumtext', + 'rawcolumn' => 'raw', 'remembertoken' => 'string', 'set' => 'set', 'smallincrements' => 'smallinteger', + 'smallinteger' => 'smallinteger', 'softdeletes' => 'timestamp', 'softdeletesdatetime' => 'datetime', + 'softdeletestz' => 'timestamptz', 'string' => 'string', 'text' => 'text', 'time' => 'time', + 'timestamp' => 'timestamp', 'timestamptz' => 'timestamptz', 'timetz' => 'timetz', + 'tinyincrements' => 'tinyinteger', 'tinyinteger' => 'tinyinteger', 'tinytext' => 'tinytext', + 'tsvector' => 'tsvector', 'ulid' => 'char', 'unsignedbiginteger' => 'biginteger', + 'unsignedinteger' => 'integer', 'unsignedmediuminteger' => 'mediuminteger', + 'unsignedsmallinteger' => 'smallinteger', 'unsignedtinyinteger' => 'tinyinteger', 'uuid' => 'uuid', + 'vector' => 'vector', 'year' => 'year', + ]; + + /** The column names Blueprint methods use when none is given. */ + private const array DEFAULT_COLUMN_NAMES = [ + 'id' => 'id', 'ipaddress' => 'ip_address', 'macaddress' => 'mac_address', 'remembertoken' => 'remember_token', + 'softdeletes' => 'deleted_at', 'softdeletesdatetime' => 'deleted_at', 'softdeletestz' => 'deleted_at', + 'ulid' => 'ulid', 'uuid' => 'uuid', + ]; + + /** Blueprint methods that add an unsigned column. */ + private const array UNSIGNED_COLUMN_METHODS = [ + 'bigincrements', 'foreignid', 'id', 'increments', 'integerincrements', 'mediumincrements', 'smallincrements', + 'tinyincrements', 'unsignedbiginteger', 'unsignedinteger', 'unsignedmediuminteger', 'unsignedsmallinteger', + 'unsignedtinyinteger', + ]; + + /** Blueprint methods whose third parameter decides whether the column is unsigned. */ + private const array SIGNABLE_COLUMN_METHODS = ['biginteger', 'integer', 'mediuminteger', 'smallinteger', 'tinyinteger']; + + /** Blueprint methods that add a nullable column. */ + private const array NULLABLE_COLUMN_METHODS = ['remembertoken', 'softdeletes', 'softdeletesdatetime', 'softdeletestz']; + + /** Blueprint methods that return a ForeignIdColumnDefinition, whose constrained() and references() return its foreign key. */ + private const array FOREIGN_ID_COLUMN_METHODS = ['foreignid', 'foreignidfor', 'foreignulid', 'foreignulidfor', 'foreignuuid', 'foreignuuidfor']; + + /** Blueprint methods that change no columns. */ + private const array IGNORED_BLUEPRINT_METHODS = [ + 'charset', 'collation', 'comment', 'create', 'dropforeign', 'dropfulltext', 'dropindex', 'dropprimary', + 'dropspatialindex', 'dropunique', 'dropvectorindex', 'engine', 'foreign', 'fulltext', 'index', 'innodb', + 'partitionbyrange', 'primary', 'rawindex', 'renameindex', 'spatialindex', 'temporary', 'unique', 'vectorindex', + ]; + + /** + * The type names each driver's schema grammar creates Blueprint types with, by Blueprint type. + * + * The grammar is not run: MySQL's date and year types read the live server version. Spatial, + * vector and raw types have no fixed name, so their columns are unknown. + */ + private const array GRAMMAR_TYPES = [ + 'mysql' => [ + 'biginteger' => 'bigint', 'binary' => 'blob', 'boolean' => 'tinyint', 'char' => 'char', 'date' => 'date', + 'datetime' => 'datetime', 'datetimetz' => 'datetime', 'decimal' => 'decimal', 'double' => 'double', + 'enum' => 'enum', 'float' => 'float', 'integer' => 'int', 'ipaddress' => 'varchar', 'json' => 'json', + 'jsonb' => 'json', 'longbinary' => 'longblob', 'longtext' => 'longtext', 'macaddress' => 'varchar', + 'mediumbinary' => 'mediumblob', 'mediuminteger' => 'mediumint', 'mediumtext' => 'mediumtext', 'set' => 'set', + 'smallinteger' => 'smallint', 'string' => 'varchar', 'text' => 'text', 'time' => 'time', 'timetz' => 'time', + 'timestamp' => 'timestamp', 'timestamptz' => 'timestamp', 'tinyinteger' => 'tinyint', + 'tinytext' => 'tinytext', 'uuid' => 'char', 'year' => 'year', + ], + 'pgsql' => [ + 'biginteger' => 'int8', 'binary' => 'bytea', 'boolean' => 'bool', 'char' => 'bpchar', 'date' => 'date', + 'datetime' => 'timestamp', 'datetimetz' => 'timestamptz', 'decimal' => 'numeric', 'double' => 'float8', + 'enum' => 'varchar', 'float' => 'float8', 'integer' => 'int4', 'ipaddress' => 'inet', 'json' => 'json', + 'jsonb' => 'jsonb', 'longbinary' => 'bytea', 'longtext' => 'text', 'macaddress' => 'macaddr', + 'mediumbinary' => 'bytea', 'mediuminteger' => 'int4', 'mediumtext' => 'text', 'smallinteger' => 'int2', + 'string' => 'varchar', 'text' => 'text', 'time' => 'time', 'timetz' => 'timetz', 'timestamp' => 'timestamp', + 'timestamptz' => 'timestamptz', 'tinyinteger' => 'int2', 'tinytext' => 'varchar', 'tsvector' => 'tsvector', + 'uuid' => 'uuid', 'year' => 'int4', + ], + 'sqlite' => [ + 'biginteger' => 'integer', 'binary' => 'blob', 'boolean' => 'tinyint', 'char' => 'varchar', 'date' => 'date', + 'datetime' => 'datetime', 'datetimetz' => 'datetime', 'decimal' => 'numeric', 'double' => 'double', + 'enum' => 'varchar', 'float' => 'float', 'integer' => 'integer', 'ipaddress' => 'varchar', 'json' => 'text', + 'jsonb' => 'text', 'longbinary' => 'blob', 'longtext' => 'text', 'macaddress' => 'varchar', + 'mediumbinary' => 'blob', 'mediuminteger' => 'integer', 'mediumtext' => 'text', 'smallinteger' => 'integer', + 'string' => 'varchar', 'text' => 'text', 'time' => 'time', 'timetz' => 'time', 'timestamp' => 'datetime', + 'timestamptz' => 'datetime', 'tinyinteger' => 'integer', 'tinytext' => 'text', 'uuid' => 'varchar', + 'year' => 'integer', + ], + ]; + + /** Whether an earlier migration could have changed any connection, so connections first seen later are incomplete. */ + private bool $invalidated = false; + + /** The type of the key columns morphs() adds, or null when a migration changed it to an unknown value. */ + private ?string $morphKeyType; + + /** @var array the declarations of the helper methods being applied, to stop recursion */ + private array $appliedMethods = []; + + /** @var array whether each class or trait migrations use code from sets the connection property */ + private array $connectionWriters = []; + + /** + * @var WeakMap> the builder variables of each + * function the current migration + * applies, by the connection it runs for + */ + private WeakMap $builderVariables; + + /** + * @var array the PHP files besides the migrations that the facts were read from, such as + * a constant naming a table; those read for one table's columns are its sources + */ + public array $sources = []; + + /** + * Create a new schema aggregator instance. + * + * @param array $connections the schema facts, by owning connection + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ConnectionConfigResolver $connectionConfigResolver, + private Parser $parser, + public array $connections = [], + ) { + $this->morphKeyType = Builder::$defaultMorphKeyType; + } + + /** + * Apply the migration classes a file declares. + * + * @param array $stmts + */ + public function addStatements(array $stmts): void + { + $nodeFinder = new NodeFinder; + + /** @var PhpParser\Node\Stmt\Class_[] $classes */ + $classes = $nodeFinder->findInstanceOf($stmts, PhpParser\Node\Stmt\Class_::class); + + foreach ($classes as $stmt) { + $this->addClassStatements($stmt); + } + } + + /** + * Make every connection's facts and the morph key type unknown, for migration code that cannot be read. + */ + public function invalidate(): void + { + $this->forgetEveryConnection(); + + // Unread code may also change the key type morphs() adds, such as through Schema::morphUsingUuids(). + $this->morphKeyType = null; + } + + /** + * Make every connection's facts unknown. + */ + private function forgetEveryConnection(): void + { + foreach ($this->connections as $connection) { + $connection->forget(); + } + + $this->invalidated = true; + } + + /** + * Apply a migration class's up() method, which it may inherit. + * + * Unlike upstream, which applied every method but down() in declaration order, helper methods + * are applied where up() calls them. + */ + private function addClassStatements(PhpParser\Node\Stmt\Class_ $class): void + { + // An inherited method's builder variables can depend on the migration that runs it, such as + // its $connection, so each migration starts without the previous one's. + $this->builderVariables = new WeakMap; + + // The migration's parents and traits decide which method a call runs and whether inherited code sets the + // connection, so an edit to any of them can change the facts while the file of the method that runs does not. + $uses = $class->extends === null ? [] : [$class->extends]; + + foreach ($class->getTraitUses() as $traitUse) { + $uses = [...$uses, ...$traitUse->traits]; + } + + foreach ($uses as $use) { + if ($this->reflectionProvider->hasClass($use->toString())) { + $this->addSources(SourceFiles::forClass($this->reflectionProvider->getClass($use->toString()), Migration::class), null); + } + } + + $scope = ['class' => $class, 'declaring' => null, 'connection' => $this->getMigrationConnection($class), 'table' => null, 'builders' => [], 'escaped' => []]; + $up = $this->findMethod($scope, null, 'up'); + + if ($up === false) { + $this->invalidate(); + + return; + } + + if ($up === null || $up[0]->stmts === null) { + return; + } + + $this->applyBody($up[0]->stmts, $this->getFunctionScope($up[0], ['declaring' => $up[1]] + $scope)); + } + + /** + * Apply a whole body, joining the outcomes that returned early with the one that reached its end. + * + * @param PhpParser\Node\Stmt[] $stmts + * @param Scope $scope + */ + private function applyBody(array $stmts, array $scope): void + { + [$continues, $returned] = $this->applyStatements($stmts, $scope); + + // Without an early return, the outcome that reached the end is already current. + if ($returned !== null) { + $this->restore($continues ? $this->mergeStates($returned, $this->snapshot()) : $returned); + } + } + + /** + * Get the connection a migration's statements run on by default, or null when it is unknown. + * + * Migrator::runMethod() makes the routed connection of the migration's own connection, or the + * default connection, the default while the migration runs. + */ + private function getMigrationConnection(PhpParser\Node\Stmt\Class_ $class): ?ConnectionConfig + { + [$known, $name] = $this->evaluateGetConnection($class); + + if (! $known) { + return null; + } + + // The migrator takes an empty name for the default connection. + $name = $name === null || $name === '' ? $this->connectionConfigResolver->getDefaultConnectionName() : $name; + $connection = $name === null ? null : $this->connectionConfigResolver->connection($name); + + return $connection === null ? null : $this->connectionConfigResolver->connection($connection->owner); + } + + /** + * Evaluate the connection name a migration's getConnection() returns: whether it is known, and the name. + * + * Only an override that returns one expression is read. An inherited method is read only when it + * is Migration's own, which returns the connection property. + * + * @return array{bool, ?string} + */ + private function evaluateGetConnection(PhpParser\Node\Stmt\Class_ $class): array + { + $method = $class->getMethod('getConnection'); + + if ($method === null) { + return $this->inheritsFromMigration($class, 'getConnection') ? $this->evaluateConnectionProperty($class) : [false, null]; + } + + $stmts = $method->stmts ?? []; + + if (count($stmts) !== 1 || ! $stmts[0] instanceof PhpParser\Node\Stmt\Return_ || $stmts[0]->expr === null) { + return [false, null]; + } + + [$known, $name] = $this->evaluateConnectionName($stmts[0]->expr, $class, false); + + // A string return type throws on null rather than selecting the default connection. + if ($name === null && $method->returnType instanceof PhpParser\Node\Identifier && $method->returnType->toLowerString() === 'string') { + return [false, null]; + } + + return [$known, $name]; + } + + /** + * Evaluate a migration's connection property: whether its value is known, and the value. + * + * Its declared default is read, or Migration's own null default when it is inherited from Migration. + * + * @return array{bool, ?string} + */ + private function evaluateConnectionProperty(PhpParser\Node\Stmt\Class_ $class): array + { + if ($this->writesConnectionProperty($class)) { + return [false, null]; + } + + foreach ($class->getProperties() as $property) { + foreach ($property->props as $prop) { + if ($prop->name->toString() === 'connection') { + return match (true) { + $prop->default === null || $this->isNullLiteral($prop->default) => [true, null], + $prop->default instanceof PhpParser\Node\Scalar\String_ => [true, $prop->default->value], + default => [false, null], + }; + } + } + } + + return $this->inheritsFromMigration($class, 'connection') ? [true, null] : [false, null]; + } + + /** + * Determine whether a migration sets its connection property at runtime, in its own code or code it uses + * from its traits and the classes between it and Migration. + */ + private function writesConnectionProperty(PhpParser\Node\Stmt\Class_ $class): bool + { + if ($this->methodsWriteConnection($class->getMethods())) { + return true; + } + + foreach ($class->getTraitUses() as $traitUse) { + foreach ($traitUse->traits as $trait) { + if ($this->inheritedCodeWritesConnection($trait->toString())) { + return true; + } + } + } + + return $class->extends !== null && $this->inheritedCodeWritesConnection($class->extends->toString()); + } + + /** + * Determine whether a class or trait a migration uses code from sets the connection property at runtime, in + * its own methods, its traits' or those of its parents up to Migration. + */ + private function inheritedCodeWritesConnection(string $className): bool + { + if (isset($this->connectionWriters[$className])) { + return $this->connectionWriters[$className]; + } + + // A class that cannot be read could set it. + if (! $this->reflectionProvider->hasClass($className)) { + return $this->connectionWriters[$className] = true; + } + + $classReflection = $this->reflectionProvider->getClass($className); + + if ($classReflection->getName() === Migration::class) { + return $this->connectionWriters[$className] = false; + } + + $methods = []; + + foreach ($classReflection->getNativeReflection()->getMethods() as $method) { + // A trait's methods belong to the class using it. + if ($method->getDeclaringClass()->getName() !== $classReflection->getName()) { + continue; + } + + $declaredMethod = $this->findDeclaredMethod($className, $method->getName()); + + if ($declaredMethod === false) { + return $this->connectionWriters[$className] = true; + } + + if ($declaredMethod !== null) { + $methods[] = $declaredMethod[0]; + } + } + + $parent = $classReflection->getParentClass(); + + return $this->connectionWriters[$className] = $this->methodsWriteConnection($methods) + || ($parent !== null && $this->inheritedCodeWritesConnection($parent->getName())); + } + + /** + * Determine whether any of the given methods sets the connection property: a method other than down(), which + * runs on its own, or a promoted constructor parameter. A write in a nested class only makes this cautious. + * + * @param list $methods + */ + private function methodsWriteConnection(array $methods): bool + { + foreach ($methods as $method) { + if ($method->name->toLowerString() === 'down') { + continue; + } + + foreach ($method->params as $param) { + if ($param->flags !== 0 && $param->var instanceof PhpParser\Node\Expr\Variable && $param->var->name === 'connection') { + return true; + } + } + + $write = (new NodeFinder)->findFirst($method->stmts ?? [], fn (PhpParser\Node $node): bool => ($node instanceof PhpParser\Node\Expr\Assign + || $node instanceof PhpParser\Node\Expr\AssignRef + || $node instanceof PhpParser\Node\Expr\AssignOp) + && $node->var instanceof PhpParser\Node\Expr\PropertyFetch + && $this->isThis($node->var->var) + && $node->var->name instanceof PhpParser\Node\Identifier + && $node->var->name->toString() === 'connection'); + + if ($write !== null) { + return true; + } + } + + return false; + } + + /** + * Determine whether a class inherits a method, or the property when no method is named, from + * Migration itself rather than from a class between them or a trait it uses. + */ + private function inheritsFromMigration(PhpParser\Node\Stmt\Class_ $class, string $member): bool + { + if ($class->extends === null || ! $this->reflectionProvider->hasClass($class->extends->toString())) { + return false; + } + + // A trait's member, or a member an alias names, is not read, as for a method found through findMethod(). + foreach ($class->getTraitUses() as $traitUse) { + foreach ($traitUse->adaptations as $adaptation) { + if ($adaptation instanceof PhpParser\Node\Stmt\TraitUseAdaptation\Alias + && $adaptation->newName?->toLowerString() === strtolower($member)) { + return false; + } + } + + foreach ($traitUse->traits as $trait) { + if (! $this->reflectionProvider->hasClass($trait->toString())) { + return false; + } + + $traitReflection = $this->reflectionProvider->getClass($trait->toString()); + + if ($member === 'connection' ? $traitReflection->hasNativeProperty($member) : $traitReflection->hasNativeMethod($member)) { + return false; + } + } + } + + $parent = $this->reflectionProvider->getClass($class->extends->toString()); + $declaringClass = $member === 'connection' + ? ($parent->hasNativeProperty($member) ? $parent->getNativeProperty($member)->getDeclaringClass() : null) + : ($parent->hasNativeMethod($member) ? $parent->getNativeMethod($member)->getDeclaringClass() : null); + + return $declaringClass?->getName() === Migration::class; + } + + /** + * Evaluate a connection name expression: whether its value is known, and the value. + * + * Only string and null literals, the migration's connection property and getConnection(), config + * reads with a literal key and an evaluated default, and null coalescing of these are evaluated. + * Coalescing evaluates its right side only when the left one is null, as it runs. + * + * @param bool $method whether getConnection() can be evaluated, which is not the case inside it + * @return array{bool, ?string} + */ + private function evaluateConnectionName(PhpParser\Node\Expr $expr, PhpParser\Node\Stmt\Class_ $class, bool $method = true): array + { + if ($expr instanceof PhpParser\Node\Scalar\String_) { + return [true, $expr->value]; + } + + if ($this->isNullLiteral($expr)) { + return [true, null]; + } + + if ($expr instanceof PhpParser\Node\Expr\BinaryOp\Coalesce) { + $left = $this->evaluateConnectionName($expr->left, $class, $method); + + return $left[0] && $left[1] === null ? $this->evaluateConnectionName($expr->right, $class, $method) : $left; + } + + if ($expr instanceof PhpParser\Node\Expr\PropertyFetch && $this->isThis($expr->var) + && $expr->name instanceof PhpParser\Node\Identifier && $expr->name->toString() === 'connection') { + return $this->evaluateConnectionProperty($class); + } + + if ($method && $expr instanceof PhpParser\Node\Expr\MethodCall && $this->isThis($expr->var) + && $expr->name instanceof PhpParser\Node\Identifier && $expr->name->toLowerString() === 'getconnection' + && $expr->getArgs() === [] && ! $expr->isFirstClassCallable()) { + return $this->evaluateGetConnection($class); + } + + // config('key'), config()->get('key') or config()->string('key'), and the same Config facade methods. + $repository = ($expr instanceof PhpParser\Node\Expr\MethodCall && $expr->var instanceof PhpParser\Node\Expr\FuncCall + && $this->isConfigFunction($expr->var) && $expr->var->getArgs() === []) + || ($expr instanceof PhpParser\Node\Expr\StaticCall && $this->isFacade($expr->class, Config::class)); + $read = match (true) { + $expr instanceof PhpParser\Node\Expr\FuncCall && $this->isConfigFunction($expr) => 'get', + $repository && $expr->name instanceof PhpParser\Node\Identifier => $expr->name->toLowerString(), + default => null, + }; + + if (! in_array($read, ['get', 'string'], true)) { + return [false, null]; + } + + /** @var PhpParser\Node\Expr\FuncCall|PhpParser\Node\Expr\MethodCall|PhpParser\Node\Expr\StaticCall $expr */ + $key = $this->getArgument($expr, 0, 'key'); + + if (! $key instanceof PhpParser\Node\Scalar\String_) { + return [false, null]; + } + + [$present, $value] = $this->connectionConfigResolver->getConfiguredName($key->value); + + if (! $present) { + $default = $this->getArgument($expr, 1, 'default'); + [$known, $value] = $default === null ? [true, null] : $this->evaluateConnectionName($default, $class, $method); + + if (! $known) { + return [false, null]; + } + } + + // A value that is not a name is unknown, and Repository::string() throws for anything but a string. + return $value === false || ($read === 'string' && $value === null) ? [false, null] : [true, $value]; + } + + /** + * Determine whether a call is to the global config() helper. + */ + private function isConfigFunction(PhpParser\Node\Expr\FuncCall $call): bool + { + return $call->name instanceof PhpParser\Node\Name && $call->name->toLowerString() === 'config' && ! $call->isFirstClassCallable(); + } + + /** + * Determine whether an expression is $this. + */ + private function isThis(PhpParser\Node\Expr $expr): bool + { + return $expr instanceof PhpParser\Node\Expr\Variable && $expr->name === 'this'; + } + + /** + * Apply a body of statements, following branches and early returns. + * + * Each outcome of a branch applies to its own copy of the facts, and the copies merge + * where the outcomes join. Other control flow makes the facts it could affect unknown. + * + * @param PhpParser\Node\Stmt[] $stmts + * @param Scope $scope + * + * @return array{bool, ?State} whether an outcome reaches the end, which leaves its facts current, + * and the joined facts of the outcomes that returned early + */ + private function applyStatements(array $stmts, array $scope): array + { + $returned = null; + + foreach ($stmts as $stmt) { + $expr = $stmt instanceof PhpParser\Node\Stmt\Expression || $stmt instanceof PhpParser\Node\Stmt\Return_ ? $stmt->expr : null; + + if ($expr !== null) { + if ($scope['table'] === null) { + $this->applyExpression($expr, $scope); + } else { + $this->processColumnUpdate($expr, $scope); + } + } + + if ($stmt instanceof PhpParser\Node\Stmt\Return_) { + return [false, $this->mergeStates($returned, $this->snapshot())]; + } + + // A switch that a case leaves partway, such as through a break in an if, is not followed, as loops are not. + if ($stmt instanceof PhpParser\Node\Stmt\If_ || ($stmt instanceof PhpParser\Node\Stmt\Switch_ && ! $this->leavesSwitchPartway($stmt))) { + [$continuing, $branchReturned] = $this->applyBranches($stmt, $scope); + $returned = $this->mergeStates($returned, $branchReturned); + + if ($continuing === null) { + return [false, $returned]; + } + + $this->restore($continuing); + } elseif (! $stmt instanceof PhpParser\Node\Stmt\Expression && ! $stmt instanceof PhpParser\Node\Stmt\Nop) { + $this->forgetAffectedBy($stmt, $scope); + } + } + + return [true, $returned]; + } + + /** + * Determine whether a case leaves the switch other than at a break among its own statements, where its body ends. + */ + private function leavesSwitchPartway(PhpParser\Node\Stmt\Switch_ $switch): bool + { + foreach ($switch->cases as $case) { + foreach ($case->stmts as $stmt) { + if ($stmt instanceof PhpParser\Node\Stmt\Break_) { + break; + } + + if ($this->leavesSwitch([$stmt], 0)) { + return true; + } + } + } + + return false; + } + + /** + * Determine whether nodes hold a break or continue that leaves the switch around them, past the loops and + * switches between them. A function or class starts its own. + * + * @param array $nodes + * @param int $depth the loops and switches between the nodes and the switch + */ + private function leavesSwitch(array $nodes, int $depth): bool + { + foreach ($nodes as $node) { + if ($node instanceof PhpParser\Node\Stmt\Break_ || $node instanceof PhpParser\Node\Stmt\Continue_) { + if (($node->num instanceof PhpParser\Node\Scalar\Int_ ? $node->num->value : 1) > $depth) { + return true; + } + + continue; + } + + if (! $node instanceof PhpParser\Node || $node instanceof PhpParser\Node\FunctionLike || $node instanceof PhpParser\Node\Stmt\ClassLike) { + continue; + } + + $inner = $node instanceof PhpParser\Node\Stmt\For_ || $node instanceof PhpParser\Node\Stmt\Foreach_ + || $node instanceof PhpParser\Node\Stmt\While_ || $node instanceof PhpParser\Node\Stmt\Do_ + || $node instanceof PhpParser\Node\Stmt\Switch_ ? $depth + 1 : $depth; + + foreach ($node->getSubNodeNames() as $name) { + $subNode = $node->{$name}; + + if ($this->leavesSwitch(is_array($subNode) ? $subNode : [$subNode], $inner)) { + return true; + } + } + } + + return false; + } + + /** + * Apply each outcome of an if or switch statement to its own copy of the facts. + * + * A condition decided from established facts skips the outcomes it rules out. + * + * @param Scope $scope + * + * @return array{?State, ?State} the joined facts of the outcomes that continue, and of those that returned + */ + private function applyBranches(PhpParser\Node\Stmt\If_|PhpParser\Node\Stmt\Switch_ $stmt, array $scope): array + { + $this->forgetAffectedBy($stmt->cond, $scope); + + if ($stmt instanceof PhpParser\Node\Stmt\Switch_) { + // Case expressions run until one matches, so their effects may reach any outcome. + foreach ($stmt->cases as $case) { + if ($case->cond !== null) { + $this->forgetAffectedBy($case->cond, $scope); + } + } + } + + // The facts when every condition so far was false. + $falsePath = $this->snapshot(); + $continuing = $returned = null; + $fallthrough = true; + + if ($stmt instanceof PhpParser\Node\Stmt\Switch_) { + $branches = []; + + // A case without a break falls into the next one, so each case runs the statements up to a break. + foreach ($stmt->cases as $index => $case) { + $body = []; + + foreach ($stmt->cases as $following => $next) { + if ($following < $index) { + continue; + } + + foreach ($next->stmts as $caseStmt) { + if ($caseStmt instanceof PhpParser\Node\Stmt\Break_) { + break 2; + } + + $body[] = $caseStmt; + } + } + + $branches[] = [null, $body]; + $fallthrough = $fallthrough && $case->cond !== null; + } + } else { + $branches = [[$stmt->cond, $stmt->stmts]]; + + foreach ($stmt->elseifs as $elseif) { + $branches[] = [$elseif->cond, $elseif->stmts]; + } + + if ($stmt->else !== null) { + $branches[] = [null, $stmt->else->stmts]; + $fallthrough = false; + } + } + + foreach ($branches as $index => [$condition, $body]) { + $this->restore($falsePath); + + // An elseif condition runs only after the earlier conditions were false. + if ($condition !== null && $index > 0) { + $this->forgetAffectedBy($condition, $scope); + $falsePath = $this->snapshot(); + } + + $decision = $condition === null ? null : $this->decide($condition, $scope); + + if ($decision === false) { + continue; + } + + [$continues, $branchReturned] = $this->applyStatements($body, $scope); + $returned = $this->mergeStates($returned, $branchReturned); + $continuing = $continues ? $this->mergeStates($continuing, $this->snapshot()) : $continuing; + + // A condition that is certainly true rules out the later outcomes. + if ($decision === true && ! $stmt instanceof PhpParser\Node\Stmt\Switch_) { + $fallthrough = false; + + break; + } + } + + if ($fallthrough) { + $continuing = $this->mergeStates($continuing, $falsePath); + } + + return [$continuing, $returned]; + } + + /** + * Decide a Schema::hasTable() or hasColumn() guard from established facts, or return null when it is unknown. + * + * Only a known connection and a complete table decide it, also through a selected builder, + * negation and comparison with true or false. + * + * @param Scope $scope + */ + private function decide(PhpParser\Node\Expr $condition, array $scope): ?bool + { + if ($condition instanceof PhpParser\Node\Expr\BooleanNot) { + $decision = $this->decide($condition->expr, $scope); + + return $decision === null ? null : ! $decision; + } + + if ($condition instanceof PhpParser\Node\Expr\BinaryOp\Identical || $condition instanceof PhpParser\Node\Expr\BinaryOp\NotIdentical) { + foreach ([[$condition->left, $condition->right], [$condition->right, $condition->left]] as [$guard, $constant]) { + if ($constant instanceof PhpParser\Node\Expr\ConstFetch && in_array($constant->name->toLowerString(), ['true', 'false'], true)) { + $decision = $this->decide($guard, $scope); + + if ($decision === null) { + return null; + } + + $expected = $constant->name->toLowerString() === 'true'; + + return ($decision === $expected) === $condition instanceof PhpParser\Node\Expr\BinaryOp\Identical; + } + } + + return null; + } + + $call = $this->getSchemaCall($condition, $scope); + + if ($call === null || ! in_array($call['method'], ['hastable', 'hascolumn'], true) || $call['connection'] === null) { + return null; + } + + return $this->decideGuard( + $call['connection'], + $this->getNameArgument($call['call'], 0, 'table'), + $call['method'] === 'hascolumn' ? $this->getNameArgument($call['call'], 1, 'column') : null, + $call['method'] === 'hascolumn', + $scope['table'], + ); + } + + /** + * Decide whether a table, or a column of it, exists from established facts, or return null when it is unknown. + * + * @param ?TableScope $pending the table of the Blueprint callback the guard runs in + */ + private function decideGuard(ConnectionConfig $connection, ?string $tableName, ?string $columnName, bool $checksColumn, ?array $pending = null): ?bool + { + if ($tableName === null) { + return null; + } + + [$schema, $name] = $connection->physicalTable($tableName); + $owner = $this->owner($connection); + $key = $this->findTableKey($connection, $schema, $name); + + if ($key === false) { + return null; + } + + $table = $key === null ? null : $owner->tables[$key]; + $pendingKey = null; + + if ($pending !== null && $pending['connection']->owner === $connection->owner) { + $current = $owner->tables[$pending['key']] ?? null; + + // A queued drop or rename, which the facts already apply, leaves them unlike the database until the + // callback returns, whatever name or connection the guard uses. + if ($current === null) { + return null; + } + + // Blueprint commands run once the callback returns, so a guard on the callback's own table reads the + // table as it was before them. An immediate change to it, such as another Schema::table() call, makes + // that unknown. + if ($key === $pending['key']) { + $this->sources += $current->sources; + + if (! $current->complete) { + return null; + } + + $pendingKey = $key; + $table = $pending['before']; + $key = $table === null ? null : $pendingKey; + } + } + + // The outcome the guard selects can change any table, so the files the table's facts were read from + // become sources of every fact. + if ($table !== null) { + $this->sources += $table->sources; + } + + // Both guards can match names in another letter case, depending on the database. + if (! $checksColumn) { + return match (true) { + $table !== null && $table->complete => true, + $key === null && $owner->complete + && ! array_any($owner->tables, static fn (SchemaTable $known, string $knownKey): bool => $knownKey !== $pendingKey + && mb_strtolower($known->name) === mb_strtolower($name)) => false, + default => null, + }; + } + + if ($columnName === null || $table === null || ! $table->complete) { + return null; + } + + $matches = array_filter($table->columns, static fn (SchemaColumn $column): bool => mb_strtolower($column->name) === mb_strtolower($columnName)); + + // A column of unknown type may be one that only some earlier outcomes added. + return match (true) { + $matches === [] => false, + array_any($matches, static fn (SchemaColumn $column): bool => $column->type !== null) => true, + default => null, + }; + } + + /** + * Apply an expression statement of a migration body. + * + * @param Scope $scope + */ + private function applyExpression(PhpParser\Node\Expr $expr, array $scope): void + { + // A first-class callable created as a statement is discarded without running. + if ($expr instanceof PhpParser\Node\Expr\CallLike && $expr->isFirstClassCallable()) { + return; + } + + $call = $this->getSchemaCall($expr, $scope); + + if ($call !== null && in_array($call['method'], self::MORPH_KEY_SCHEMA_METHODS, true)) { + $this->morphKeyType = match ($call['method']) { + 'morphusinguuids' => 'uuid', + 'morphusingulids' => 'ulid', + default => $this->getNameArgument($call['call'], 0, 'type'), + }; + + return; + } + + if ($call !== null && $call['connection'] !== null) { + switch ($call['method']) { + case 'create': + case 'table': + $this->alterTable($call['call'], $call['connection'], $call['method'] === 'create', $scope); + + return; + case 'drop': + case 'dropifexists': + $this->dropTable($call['call'], $call['connection']); + + return; + case 'rename': + $this->renameTableThroughStaticCall($call['call'], $call['connection']); + + return; + case 'withoutforeignkeyconstraints': + // The callback's Schema calls still run on the default connection. + $this->applyCallback($call['call'], 0, 'callback', $scope); + + return; + case 'whentablehascolumn': + case 'whentabledoesnthavecolumn': + case 'whentablehasindex': + case 'whentabledoesnthaveindex': + $this->applyConditionalTable($call['call'], $call['connection'], $call['method'], $scope); + + return; + } + } + + $statement = $this->literalDatabaseStatement($expr, $scope); + + if ($statement !== null && $statement[0] !== null && $statement[1] !== null) { + $this->applyLiteralSql($statement[0], $statement[1]); + + return; + } + + $helper = $expr instanceof PhpParser\Node\Expr\MethodCall || $expr instanceof PhpParser\Node\Expr\StaticCall + ? $this->getHelperMethod($expr, $scope) + : null; + + if ($helper === false) { + $this->invalidate(); + + return; + } + + if ($helper !== null) { + $declaration = $this->getDeclaration($helper); + + if (! isset($this->appliedMethods[$declaration])) { + // The arguments run before the helper, which receives them in parameters it does not follow. + $this->forgetAffectedBy($expr->getRawArgs(), $scope); + + $this->appliedMethods[$declaration] = true; + $this->applyBody($helper[0]->stmts ?? [], $this->getFunctionScope($helper[0], ['declaring' => $helper[1]] + $scope)); + unset($this->appliedMethods[$declaration]); + + return; + } + } + + $this->forgetAffectedBy($expr, $scope); + } + + /** + * Apply a Schema::whenTable...() helper, which runs table() with its callback when its guard holds. + * + * Index guards are never decided, since indexes are not tracked, so both outcomes are joined. + * + * @param Scope $scope + */ + private function applyConditionalTable(PhpParser\Node\Expr\CallLike $call, ConnectionConfig $connection, string $method, array $scope): void + { + $decision = str_contains($method, 'column') + ? $this->decideGuard($connection, $this->getNameArgument($call, 0, 'table'), $this->getNameArgument($call, 1, 'column'), true) + : null; + + if ($decision !== null && str_contains($method, 'doesnt')) { + $decision = ! $decision; + } + + if ($decision === false) { + return; + } + + $skipped = $decision === null ? $this->snapshot() : null; + + $this->alterTable($call, $connection, false, $scope, 2); + + if ($skipped !== null) { + $this->restore($this->mergeStates($this->snapshot(), $skipped)); + } + } + + /** + * Get the method a call on $this, self, static or parent runs, which up() runs inline, with the class + * self:: names in it. + * + * $this, $this:: and static:: dispatch on the migration, except that $this reaches a private method of the + * class declaring the calling body first; self:: dispatches on that class, and parent:: on its parent. + * + * @param Scope $scope + * @return null|array{PhpParser\Node\Stmt\ClassMethod, ?string}|false null for another call, false for a + * method whose body cannot be read or + * whose name is not constant + */ + private function getHelperMethod(PhpParser\Node\Expr\MethodCall|PhpParser\Node\Expr\StaticCall $call, array $scope): false|array|null + { + if ($call instanceof PhpParser\Node\Expr\MethodCall) { + $class = $this->isThis($call->var) ? '$this' : null; + } else { + $class = match (true) { + $call->class instanceof PhpParser\Node\Name => $call->class->toLowerString(), + // $this:: names the migration's class, as static:: does. + $this->isThis($call->class) => 'static', + default => null, + }; + } + + if (! in_array($class, ['$this', 'static', 'self', 'parent'], true)) { + return null; + } + + // A dynamic name can run any of the migration's methods. + if (! $call->name instanceof PhpParser\Node\Identifier) { + return false; + } + + $name = $call->name->toString(); + + if ($class === '$this') { + // PHP runs a private method of the class whose code calls it, even when the migration declares its own. + if ($scope['declaring'] !== null) { + $declaring = $this->reflectionProvider->getClass($scope['declaring']); + + if ($declaring->hasNativeMethod($name) + && $declaring->getNativeMethod($name)->isPrivate() + && $declaring->getNativeMethod($name)->getDeclaringClass()->getName() === $scope['declaring']) { + return $this->findDeclaredMethod($scope['declaring'], $name); + } + } + + return $this->findMethod($scope, null, $name); + } + + if ($class === 'parent') { + // A declaring class came from reflection, so it is known. + $parent = $scope['declaring'] === null + ? $scope['class']->extends?->toString() + : $this->reflectionProvider->getClass($scope['declaring'])->getParentClass()?->getName(); + + return $parent === null ? null : $this->findDeclaredMethod($parent, $name); + } + + return $this->findMethod($scope, $class === 'self' ? $scope['declaring'] : null, $name); + } + + /** + * Get the call a callable array naming one of the migration's methods makes, such as [$this, 'createUsers']. + * + * Its method name is not constant unless the second item is a string literal. + */ + private function getCallableArrayCall(PhpParser\Node\Expr\Array_ $array): PhpParser\Node\Expr\MethodCall|PhpParser\Node\Expr\StaticCall|null + { + if (count($array->items) !== 2) { + return null; + } + + [$target, $method] = $array->items; + + if ($target->key !== null || $method->key !== null || $target->unpack || $method->unpack) { + return null; + } + + $name = $method->value instanceof PhpParser\Node\Scalar\String_ ? new PhpParser\Node\Identifier($method->value->value) : $method->value; + + if ($this->isThis($target->value)) { + return new PhpParser\Node\Expr\MethodCall($target->value, $name); + } + + $class = $target->value; + + return $class instanceof PhpParser\Node\Expr\ClassConstFetch + && $class->class instanceof PhpParser\Node\Name + && in_array($class->class->toLowerString(), ['static', 'self', 'parent'], true) + && $class->name instanceof PhpParser\Node\Identifier + && $class->name->toLowerString() === 'class' + ? new PhpParser\Node\Expr\StaticCall($class->class, $name) + : null; + } + + /** + * Get the declaration a helper method found for a call names, to recognize a recursive call. + * + * Parsing a file again can give new nodes, so the node itself does not identify the method. + * A method without a declaring class belongs to the migration being applied. + * + * @param array{PhpParser\Node\Stmt\ClassMethod, ?string} $helper + */ + private function getDeclaration(array $helper): string + { + return strtolower(($helper[1] ?? '') . '::' . $helper[0]->name->toString()); + } + + /** + * Find a method on the migration, or on a class it inherits from, with the class self:: names in it. + * + * Migration's own methods are not returned, since they change no schema. The migration's traits are + * followed only without conflict resolution or aliases. + * + * @param Scope $scope + * @param null|string $className the class to look the method up on, or null for the migration + * @return null|array{PhpParser\Node\Stmt\ClassMethod, ?string}|false null when there is none, false when its + * body cannot be read + */ + private function findMethod(array $scope, ?string $className, string $name): false|array|null + { + if ($className !== null) { + return $this->findDeclaredMethod($className, $name); + } + + $class = $scope['class']; + $method = $class->getMethod($name); + + if ($method !== null) { + return [$method, null]; + } + + foreach ($class->getTraitUses() as $traitUse) { + foreach ($traitUse->adaptations as $adaptation) { + // An alias supplies the name from a method of another name, which is not followed. + if ($adaptation instanceof PhpParser\Node\Stmt\TraitUseAdaptation\Alias + && $adaptation->newName?->toLowerString() === strtolower($name)) { + return false; + } + } + + foreach ($traitUse->traits as $trait) { + if (! $this->reflectionProvider->hasClass($trait->toString())) { + return false; + } + + if ($this->reflectionProvider->getClass($trait->toString())->hasNativeMethod($name)) { + $found = $traitUse->adaptations === [] ? $this->findDeclaredMethod($trait->toString(), $name) : false; + + // A trait's method belongs to the migration, which self:: names in it. + return is_array($found) ? [$found[0], null] : $found; + } + } + } + + return $class->extends === null ? null : $this->findDeclaredMethod($class->extends->toString(), $name); + } + + /** + * Find a method a named class or trait declares or inherits, parsed from its file, with the class self:: names in it. + * + * @return null|array{PhpParser\Node\Stmt\ClassMethod, string}|false null when there is none or it is Migration's own, + * false when its body cannot be read + */ + private function findDeclaredMethod(string $className, string $name): false|array|null + { + if (! $this->reflectionProvider->hasClass($className)) { + return false; + } + + $classReflection = $this->reflectionProvider->getClass($className); + + if (! $classReflection->hasNativeMethod($name)) { + return null; + } + + // The class using a trait declares its methods, which is also what self:: names in them. + $declaringClass = $classReflection->getNativeMethod($name)->getDeclaringClass(); + + if ($declaringClass->getName() === Migration::class) { + return null; + } + + // The native method carries the file and line of a trait's method. + $nativeMethod = $declaringClass->getNativeReflection()->getMethod($name); + $fileName = $nativeMethod->getFileName(); + + if ($fileName === false) { + return false; + } + + try { + $stmts = $this->parser->parseFile($fileName); + } catch (ParserErrorsException) { + return false; + } + + $method = (new NodeFinder)->findFirst( + $stmts, + static fn (PhpParser\Node $node): bool => $node instanceof PhpParser\Node\Stmt\ClassMethod + && $node->name->toLowerString() === strtolower($name) + && $node->getStartLine() === $nativeMethod->getStartLine(), + ); + + if (! $method instanceof PhpParser\Node\Stmt\ClassMethod) { + return false; + } + + return [$method, $declaringClass->getName()]; + } + + /** + * Apply the body of a callback argument, or make what it could change unknown. + * + * @param Scope $scope + */ + private function applyCallback(PhpParser\Node\Expr\CallLike $call, int $position, string $parameter, array $scope): void + { + $callback = $this->getArgument($call, $position, $parameter); + + // An arrow function reads the enclosing variables; a closure has its own. + if ($callback instanceof PhpParser\Node\Expr\ArrowFunction) { + $this->applyBody($this->getUpdateStatements($callback), $scope); + } elseif ($callback instanceof PhpParser\Node\Expr\Closure) { + $this->applyBody($this->getUpdateStatements($callback), $this->getFunctionScope($callback, $scope)); + } elseif ($callback !== null) { + $this->forgetAffectedBy($callback, $scope); + } + } + + /** + * Get a call to a Schema builder method, through the Schema facade, a selector or a builder + * variable, with its connection. + * + * The connection is null when it is unknown. An explicit connection is not routed to its migration + * connection, but its facts belong to the connection that owns its schema. A first-class callable + * is included, since a kept one can run later with arguments that are not known. The method is null + * for a dynamic name, which can run any Schema builder method. + * + * @param Scope $scope + * @return null|array{call: PhpParser\Node\Expr\MethodCall|PhpParser\Node\Expr\StaticCall, method: ?lowercase-string, connection: ?ConnectionConfig} + */ + private function getSchemaCall(PhpParser\Node\Expr $expr, array $scope): ?array + { + if (! ($expr instanceof PhpParser\Node\Expr\StaticCall || $expr instanceof PhpParser\Node\Expr\MethodCall)) { + return null; + } + + $method = $expr->name instanceof PhpParser\Node\Identifier ? $expr->name->toLowerString() : null; + + if ($expr instanceof PhpParser\Node\Expr\StaticCall) { + return $this->isFacade($expr->class, Schema::class) && $method !== 'connection' + ? ['call' => $expr, 'method' => $method, 'connection' => $scope['connection']] + : null; + } + + if ($expr->var instanceof PhpParser\Node\Expr\Variable && is_string($expr->var->name) && array_key_exists($expr->var->name, $scope['builders'])) { + return ['call' => $expr, 'method' => $method, 'connection' => $scope['builders'][$expr->var->name]]; + } + + $selector = $this->getSchemaSelector($expr->var, $scope); + + return $selector === null ? null : ['call' => $expr, 'method' => $method, 'connection' => $selector['connection']]; + } + + /** + * Get the connection of an expression that selects a Schema builder, or null when it is not one. + * + * The selectors are Schema::connection(), DB::getSchemaBuilder() and DB::connection()->getSchemaBuilder(). + * The connection is null when it is unknown. + * + * @param Scope $scope + * @return null|array{connection: ?ConnectionConfig} + */ + private function getSchemaSelector(PhpParser\Node\Expr $expr, array $scope): ?array + { + return match (true) { + $this->isFacadeCall($expr, Schema::class, 'connection') => ['connection' => $this->getNamedConnection($expr, $scope)], + // A kept getSchemaBuilder() callable selects whichever connection is the default when it runs. + $this->isFacadeCall($expr, DB::class, 'getschemabuilder') => ['connection' => $expr->isFirstClassCallable() ? null : $scope['connection']], + $expr instanceof PhpParser\Node\Expr\MethodCall && $expr->name instanceof PhpParser\Node\Identifier + && $expr->name->toLowerString() === 'getschemabuilder' && $this->isFacadeCall($expr->var, DB::class, 'connection') => ['connection' => $expr->isFirstClassCallable() ? null : $this->getNamedConnection($expr->var, $scope)], + default => null, + }; + } + + /** + * Get the connection a Schema::connection() or DB::connection() call selects, or null when it is unknown. + * + * Without a name, it selects the default connection, which is the migration's own while it runs. + * A kept first-class callable can select any connection. + * + * @param Scope $scope + */ + private function getNamedConnection(PhpParser\Node\Expr\StaticCall $selector, array $scope): ?ConnectionConfig + { + if ($selector->isFirstClassCallable()) { + return null; + } + + $name = $this->getArgument($selector, 0, 'name'); + [$known, $value] = $name === null ? [true, null] : $this->evaluateConnectionName($name, $scope['class']); + + return match (true) { + ! $known => null, + // The database manager takes an empty name for the default connection. + $value === null || $value === '' => $scope['connection'], + default => $this->connectionConfigResolver->connection($value), + }; + } + + /** + * Get the scope of a function body the migration runs inline, with the builders its variables hold. + * + * @param Scope $scope + * @return Scope + */ + private function getFunctionScope(PhpParser\Node\Stmt\ClassMethod|PhpParser\Node\Expr\Closure $function, array $scope): array + { + // A method can run on more than one connection. The resolver keeps each connection for the process, so its id is not reused. + $context = $scope['connection'] === null ? 'unknown' : 'connection ' . spl_object_id($scope['connection']); + $cached = $this->builderVariables[$function] ?? []; + $variables = $cached[$context] ??= $this->getBuilderVariables($function, $scope); + $this->builderVariables[$function] = $cached; + + // A captured variable holds what it held in the enclosing function, unless the closure selects a builder into it. + if ($function instanceof PhpParser\Node\Expr\Closure) { + foreach ($function->uses as $use) { + $name = $use->var->name; + + if (! is_string($name) || array_key_exists($name, $variables['builders']) || array_key_exists($name, $variables['escaped'])) { + continue; + } + + if (array_key_exists($name, $scope['builders'])) { + $variables['builders'][$name] = $scope['builders'][$name]; + } elseif (array_key_exists($name, $scope['escaped'])) { + $variables['escaped'][$name] = $scope['escaped'][$name]; + } + } + } + + return $variables + $scope; + } + + /** + * Get the local variables of a function that hold Schema builders. + * + * A variable holds a builder of one connection when every write to it is an assignment statement + * of a Schema selector, all selecting that connection, and every other use calls a method on it, + * also in nested functions. Other variables assigned a Schema or DB selector escape: what their + * uses run is unknown. A selector reaching a variable another way is not followed. + * + * @param Scope $scope + * @return BuilderVariables + */ + private function getBuilderVariables(PhpParser\Node\Stmt\ClassMethod|PhpParser\Node\Expr\Closure $function, array $scope): array + { + $stmts = $function->stmts ?? []; + $nodes = (new NodeFinder)->find($stmts, static fn (PhpParser\Node $node): bool => $node instanceof PhpParser\Node\Expr\Variable + || $node instanceof PhpParser\Node\Expr\MethodCall + || $node instanceof PhpParser\Node\Expr\Assign + || $node instanceof PhpParser\Node\Stmt\Expression); + + $allowed = []; + $statements = []; + $selections = []; + $qualifying = []; + + foreach ($nodes as $node) { + if ($node instanceof PhpParser\Node\Expr\MethodCall) { + $allowed[spl_object_id($node->var)] = true; + } elseif ($node instanceof PhpParser\Node\Stmt\Expression) { + $statements[spl_object_id($node->expr)] = true; + } + } + + foreach ($nodes as $node) { + if (! $node instanceof PhpParser\Node\Expr\Assign || ! $node->var instanceof PhpParser\Node\Expr\Variable || ! is_string($node->var->name)) { + continue; + } + + $selector = $this->getSchemaSelector($node->expr, $scope); + + // A kept selector callable is not a builder. + if ($selector !== null && isset($statements[spl_object_id($node)]) + && ! ($node->expr instanceof PhpParser\Node\Expr\CallLike && $node->expr->isFirstClassCallable())) { + $allowed[spl_object_id($node->var)] = true; + $qualifying[$node->var->name][] = $selector['connection']; + } elseif ($selector === null && $this->isFacadeCall($node->expr, DB::class, 'connection')) { + $selector = ['connection' => $this->getNamedConnection($node->expr, $scope)]; + } + + if ($selector !== null) { + $selections[$node->var->name][] = $selector['connection']; + } + } + + $disqualified = []; + + foreach ($nodes as $node) { + if ($node instanceof PhpParser\Node\Expr\Variable && is_string($node->name) && ! isset($allowed[spl_object_id($node)])) { + $disqualified[$node->name] = true; + } + } + + $variables = ['builders' => [], 'escaped' => []]; + + foreach ($selections as $name => $connections) { + $distinct = []; + + foreach ($connections as $connection) { + if (! in_array($connection, $distinct, true)) { + $distinct[] = $connection; + } + } + + if (! isset($disqualified[$name]) && count($qualifying[$name] ?? []) === count($connections) && count($distinct) === 1) { + $variables['builders'][$name] = $distinct[0]; + } else { + $variables['escaped'][$name] = $distinct; + } + } + + return $variables; + } + + /** + * Make the facts that uninterpretable code could change unknown. + * + * Schema changes to a named table make that table unknown; other changes make their connection + * unknown, and changes on an unknown connection make every connection unknown. The methods the + * migration calls on itself, or names in a callable array such as [$this, 'createUsers'], are searched + * too, and a dynamic method name makes every connection unknown. Inside a Blueprint callback, any use + * of the Blueprint makes its table unknown. A selector whose builder or connection escapes, and any use + * of a variable holding one, make the connection it selects unknown. Only query chains on a DB + * connection are kept. + * + * @param PhpParser\Node|PhpParser\Node[] $node + * @param Scope $scope + */ + private function forgetAffectedBy(PhpParser\Node|array $node, array $scope): void + { + $nodes = (new NodeFinder)->find($node, static fn (PhpParser\Node $found): bool => $found instanceof PhpParser\Node\Expr\Variable + || $found instanceof PhpParser\Node\Expr\Assign + || $found instanceof PhpParser\Node\Expr\StaticCall + || $found instanceof PhpParser\Node\Expr\MethodCall + || $found instanceof PhpParser\Node\Expr\Array_); + + $calls = []; + // The selectors a builder variable or a recognized call consumes, and every receiver of a method call. + $consumed = []; + $receivers = []; + + foreach ($nodes as $found) { + if ($found instanceof PhpParser\Node\Expr\Variable && is_string($found->name)) { + if ($scope['table'] !== null && $found->name === $scope['table']['variable']) { + $this->forgetTableByKey($scope['table']['connection'], $scope['table']['key']); + } + + foreach ($scope['escaped'][$found->name] ?? [] as $connection) { + $this->forgetConnection($connection); + } + } elseif ($found instanceof PhpParser\Node\Expr\Assign) { + if ($found->var instanceof PhpParser\Node\Expr\Variable && is_string($found->var->name) && array_key_exists($found->var->name, $scope['builders'])) { + $consumed[spl_object_id($found->expr)] = true; + } + } elseif ($found instanceof PhpParser\Node\Expr\StaticCall || $found instanceof PhpParser\Node\Expr\MethodCall) { + $calls[] = $found; + + if ($found instanceof PhpParser\Node\Expr\MethodCall) { + $receivers[spl_object_id($found->var)] = true; + } + } elseif ($found instanceof PhpParser\Node\Expr\Array_) { + // A callable array can run the method it names later, like a kept first-class callable. + $callable = $this->getCallableArrayCall($found); + + if ($callable !== null) { + $calls[] = $callable; + } + } + } + + foreach ($calls as $call) { + $helper = $this->getHelperMethod($call, $scope); + + if ($helper === false) { + $this->invalidate(); + + continue; + } + + if ($helper !== null) { + $declaration = $this->getDeclaration($helper); + + if (! isset($this->appliedMethods[$declaration])) { + $this->appliedMethods[$declaration] = true; + $this->forgetAffectedBy( + $helper[0]->stmts ?? [], + ['table' => null] + $this->getFunctionScope($helper[0], ['declaring' => $helper[1]] + $scope), + ); + unset($this->appliedMethods[$declaration]); + } + + continue; + } + + $schemaCall = $this->getSchemaCall($call, $scope); + + if ($schemaCall === null) { + $statement = $this->literalDatabaseStatement($call, $scope); + + if ($statement !== null) { + $this->forgetConnection($statement[0]); + } + + continue; + } + + if ($call instanceof PhpParser\Node\Expr\MethodCall) { + $consumed[spl_object_id($call->var)] = true; + } + + $method = $schemaCall['method']; + $connection = $schemaCall['connection']; + + if ($method === null) { + // A dynamic name can change the connection's relations or the morph key type. + $this->morphKeyType = null; + $this->forgetConnection($connection); + } elseif (in_array($method, self::MORPH_KEY_SCHEMA_METHODS, true)) { + $this->morphKeyType = null; + } elseif ($connection === null || ! isset(self::TABLE_SCHEMA_METHODS[$method])) { + if (! in_array($method, self::IGNORED_SCHEMA_METHODS, true)) { + $this->forgetConnection($connection); + } + } else { + $tableName = $this->getNameArgument($call, ...self::TABLE_SCHEMA_METHODS[$method]); + + if ($tableName === null) { + $this->owner($connection)->forget(); + } else { + $this->forgetTableByKey($connection, $this->resolveTable($connection, ...$connection->physicalTable($tableName))); + } + } + } + + foreach ($calls as $call) { + if (isset($consumed[spl_object_id($call)])) { + continue; + } + + $selector = $this->getSchemaSelector($call, $scope); + + if ($selector !== null) { + $this->forgetConnection($selector['connection']); + } elseif ($this->isFacadeCall($call, DB::class, 'connection') && ! isset($receivers[spl_object_id($call)])) { + $this->forgetConnection($this->getNamedConnection($call, $scope)); + } + } + } + + /** + * Make every fact of a connection unknown, or of every connection when it is unknown. + */ + private function forgetConnection(?ConnectionConfig $connection): void + { + if ($connection === null) { + $this->forgetEveryConnection(); + } else { + $this->owner($connection)->forget(); + } + } + + /** + * Make a table unknown by its identity key, unless an earlier statement removed it. + */ + private function forgetTableByKey(ConnectionConfig $connection, string $key): void + { + $owner = $this->owner($connection); + $table = $owner->tables[$key] ?? null; + + if ($table !== null) { + $owner->forgetTable($table->schema, $table->name); + } + } + + /** + * Get the schema facts of a connection's owner, starting them when it has none yet. + * + * Migrations start from an empty database, unless an earlier migration could have changed any connection. + */ + private function owner(ConnectionConfig $connection): SchemaConnection + { + if (! isset($this->connections[$connection->owner])) { + $this->connections[$connection->owner] = new SchemaConnection($connection->owner, $connection->driver); + $this->connections[$connection->owner]->complete = ! $this->invalidated; + } + + return $this->connections[$connection->owner]; + } + + /** + * Find the identity key of the relation a physical name refers to on a connection. + * + * @return null|false|string null when it is absent, and false when it is ambiguous + */ + private function findTableKey(ConnectionConfig $connection, ?string $schema, string $name): false|string|null + { + return $this->owner($connection)->findTableKey($schema, $name, $connection->defaultSchema, $connection->searchPath); + } + + /** + * Get the identity key of the relation a changed physical name refers to, adding an unknown one when it is not known. + * + * An ambiguous name could refer to any of its candidates, so it makes the connection unknown. + */ + private function resolveTable(ConnectionConfig $connection, ?string $schema, string $name): string + { + $owner = $this->owner($connection); + $key = $this->findTableKey($connection, $schema, $name); + $this->forgetCaseVariants($connection, $schema, $name); + + if (is_string($key)) { + return $key; + } + + if ($key === false) { + $owner->forget(); + } + + $schema ??= $connection->definitionSchema(); + $owner->forgetTable($schema, $name); + $key = $owner->key($schema, $name); + $owner->tables[$key]->searchPath = $schema === null ? $connection->searchPath : null; + + return $key; + } + + /** + * Make the known relations that a change to a physical name could reach through letter case unknown. + */ + private function forgetCaseVariants(ConnectionConfig $connection, ?string $schema, string $name): void + { + foreach ($this->owner($connection)->caseVariants($schema ?? $connection->defaultSchema, $name) as $key) { + $this->forgetTableByKey($connection, $key); + } + } + + /** + * Copy the current facts. + * + * @return State + */ + private function snapshot(): array + { + return [array_map(static fn (SchemaConnection $connection): SchemaConnection => clone $connection, $this->connections), $this->invalidated, $this->morphKeyType]; + } + + /** + * Make a copy of earlier facts current. + * + * @param State $state + */ + private function restore(array $state): void + { + [$connections, $this->invalidated, $this->morphKeyType] = $state; + $this->connections = array_map(static fn (SchemaConnection $connection): SchemaConnection => clone $connection, $connections); + } + + /** + * Join the facts of two outcomes, keeping only what both establish. + * + * A connection missing from one outcome has its initial facts there, since no migration used it yet. + * + * @param ?State $first + * @param ?State $second + * + * @return ($first is null ? ($second is null ? null : State) : State) + */ + private function mergeStates(?array $first, ?array $second): ?array + { + if ($first === null || $second === null) { + return $first ?? $second; + } + + $connections = []; + + foreach ($first[0] + $second[0] as $name => $connection) { + $connections[$name] = $this->mergeConnections( + $first[0][$name] ?? $this->initialConnection($connection, $first[1]), + $second[0][$name] ?? $this->initialConnection($connection, $second[1]), + ); + } + + return [$connections, $first[1] || $second[1], $first[2] === $second[2] ? $first[2] : null]; + } + + /** + * Get the facts a connection starts with before any migration uses it. + */ + private function initialConnection(SchemaConnection $connection, bool $invalidated): SchemaConnection + { + $initial = new SchemaConnection($connection->name, $connection->driver); + $initial->complete = ! $invalidated; + + return $initial; + } + + /** + * Join the facts of a connection in two outcomes. + * + * A table in one outcome only may exist, so it is incomplete; its columns are unknown too unless + * the other outcome rules the table out. A column whose types differ, or that one outcome lacks, + * has an unknown type. A user type that one outcome lacks or defines differently is uncertain. + */ + private function mergeConnections(SchemaConnection $first, SchemaConnection $second): SchemaConnection + { + $merged = new SchemaConnection($first->name, $first->driver); + $merged->complete = $first->complete && $second->complete; + $merged->unusableDumps = $first->unusableDumps; + + foreach ($first->types + $second->types as $key => $type) { + $merged->types[$key] = array_key_exists($key, $first->types) && array_key_exists($key, $second->types) + && $first->types[$key] == $second->types[$key] ? $type : null; + } + + foreach ($first->tables + $second->tables as $key => $table) { + $firstTable = $first->tables[$key] ?? null; + $secondTable = $second->tables[$key] ?? null; + $table = clone $table; + $table->complete = $firstTable !== null && $secondTable !== null && $firstTable->complete && $secondTable->complete; + $table->sources = ($firstTable->sources ?? []) + ($secondTable->sources ?? []); + + if ($firstTable === null || $secondTable === null) { + if (! ($firstTable === null ? $first : $second)->complete) { + $table->columns = []; + } + } else { + foreach ($firstTable->columns + $secondTable->columns as $name => $column) { + $firstColumn = $firstTable->columns[$name] ?? null; + $secondColumn = $secondTable->columns[$name] ?? null; + + $table->columns[$name] = $firstColumn !== null && $secondColumn !== null + && $firstColumn->type == $secondColumn->type && $firstColumn->options === $secondColumn->options + ? new SchemaColumn($column->name, $column->type, $firstColumn->nullable || $secondColumn->nullable, $column->options) + : new SchemaColumn($column->name, null, true); + } + } + + $merged->tables[$key] = $table; + } + + return $merged; + } + + /** + * Apply Schema::create() or Schema::table() and the Blueprint callback it runs. + * + * A table changed without being known keeps the columns the callback establishes, but no claim + * about others. PostgreSQL changes its known descendants too. + * + * @param Scope $scope + * @param int $callbackPosition the position of the Blueprint callback parameter + */ + private function alterTable(PhpParser\Node\Expr\CallLike $call, ConnectionConfig $connection, bool $creating, array $scope, int $callbackPosition = 1): void + { + $owner = $this->owner($connection); + $tableName = $this->getNameArgument($call, 0, 'table'); + + if ($tableName === null) { + $owner->forget(); + + return; + } + + [$schema, $name] = $connection->physicalTable($tableName); + + if ($creating) { + $schema ??= $connection->definitionSchema(); + $key = $owner->key($schema, $name); + $before = isset($owner->tables[$key]) ? clone $owner->tables[$key] : null; + $owner->tables[$key] = new SchemaTable($name, $schema); + $owner->tables[$key]->searchPath = $schema === null ? $connection->searchPath : null; + } else { + $key = $this->resolveTable($connection, $schema, $name); + $owner->forgetDescendants($key); + $before = clone $owner->tables[$key]; + } + + $callback = $this->getArgument($call, $callbackPosition, 'callback'); + $variable = $callback === null ? null : $this->getBlueprintVariable($callback); + + if ($variable === null) { + $this->forgetTableByKey($connection, $key); + + if ($callback !== null) { + $this->forgetAffectedBy($callback, $scope); + } + + return; + } + + /** @var PhpParser\Node\Expr\ArrowFunction|PhpParser\Node\Expr\Closure $callback */ + $this->applyBody( + $this->getUpdateStatements($callback), + ['table' => ['connection' => $connection, 'key' => $key, 'before' => $before, 'variable' => $variable]] + $scope, + ); + } + + /** + * Get the variable a Blueprint callback receives the Blueprint in, or null when the callback cannot be followed. + */ + private function getBlueprintVariable(PhpParser\Node\Expr $callback): ?string + { + $parameter = $callback instanceof PhpParser\Node\Expr\Closure || $callback instanceof PhpParser\Node\Expr\ArrowFunction + ? $callback->params[0] ?? null + : null; + + return $parameter !== null + && $parameter->var instanceof PhpParser\Node\Expr\Variable + && is_string($parameter->var->name) + && (! $parameter->type instanceof PhpParser\Node\Name || $this->isClass($parameter->type, Blueprint::class)) + ? $parameter->var->name + : null; + } + + /** + * Apply a statement of a Blueprint callback to its table. + * + * Only a call chain on the Blueprint is followed: its first call, with the last nullable(), unsigned() + * and type() modifiers of the chain. A method it does not know, such as a macro, or a name that + * is not constant makes the table unknown. Other statements make the table unknown when they use + * the Blueprint. Creating a first-class callable runs nothing. + * + * @param Scope $scope + */ + private function processColumnUpdate(PhpParser\Node\Expr $expr, array $scope): void + { + /** @var TableScope $tableScope */ + $tableScope = $scope['table']; + + // The engine, charset and collation properties choose table options; only the assigned value can change the schema. + if ($expr instanceof PhpParser\Node\Expr\Assign + && $expr->var instanceof PhpParser\Node\Expr\PropertyFetch + && $expr->var->var instanceof PhpParser\Node\Expr\Variable + && $expr->var->var->name === $tableScope['variable'] + && $expr->var->name instanceof PhpParser\Node\Identifier + && in_array($expr->var->name->toString(), ['engine', 'charset', 'collation'], true)) { + $this->forgetAffectedBy($expr->expr, $scope); + + return; + } + + $call = $expr; + $chain = []; + + while ($call instanceof PhpParser\Node\Expr\MethodCall && $call->var instanceof PhpParser\Node\Expr\MethodCall) { + if ($call->name instanceof PhpParser\Node\Identifier && ! $call->isFirstClassCallable()) { + $chain[] = [$call->name->toLowerString(), $call]; + } + + $call = $call->var; + } + + // A foreign ID column's constrained() or references() returns its foreign key, so the calls after it change the key. + if ($call instanceof PhpParser\Node\Expr\MethodCall + && $call->name instanceof PhpParser\Node\Identifier + && in_array($call->name->toLowerString(), self::FOREIGN_ID_COLUMN_METHODS, true)) { + foreach (array_reverse($chain, true) as $position => [$name]) { + if ($name === 'constrained' || $name === 'references') { + $chain = array_slice($chain, $position + 1); + + break; + } + } + } + + $modifiers = []; + + foreach ($chain as [$name, $modifier]) { + // The outermost modifier runs last, so it decides. + $modifiers[$name] ??= $modifier; + } + + if (! $call instanceof PhpParser\Node\Expr\MethodCall + || ! $call->var instanceof PhpParser\Node\Expr\Variable + || $call->var->name !== $tableScope['variable']) { + $this->forgetAffectedBy($expr, $scope); + + return; + } + + $table = $this->owner($tableScope['connection'])->tables[$tableScope['key']] ?? null; + $method = $call->name instanceof PhpParser\Node\Identifier ? $call->name->toLowerString() : null; + + // An earlier drop() or rename() removed the table, so later changes would fail. + if ($table === null || $call->isFirstClassCallable() || in_array($method, self::IGNORED_BLUEPRINT_METHODS, true)) { + return; + } + + // removeColumn() returns the Blueprint, so the calls chained after it change the table too. The column's + // Fluent mutators can set any attribute, including its type and nullability. + if (($method === 'removecolumn' && $call !== $expr) + || array_any($chain, static fn (array $link): bool => in_array($link[0], ['fill', 'set', 'offsetset', 'offsetunset'], true))) { + $this->forgetTableByKey($tableScope['connection'], $tableScope['key']); + + return; + } + + if ($method === null || ! $this->processStatementAlterMethod($method, $call, $table, $modifiers, $scope)) { + $this->forgetTableByKey($tableScope['connection'], $tableScope['key']); + } + } + + /** + * Apply a Blueprint method to its table, or return false when its effect is unknown. + * + * @param array $modifiers the last call of each modifier in the chain + * @param Scope $scope + */ + private function processStatementAlterMethod( + string $method, + PhpParser\Node\Expr\MethodCall $call, + SchemaTable $table, + array $modifiers, + array $scope, + ): bool { + /** @var TableScope $tableScope */ + $tableScope = $scope['table']; + $connection = $tableScope['connection']; + + if (isset(self::COLUMN_METHODS[$method]) || $method === 'addcolumn') { + // addColumn() merges its parameters over the type and name, and the chain's modifiers then override them. + $parameters = $method === 'addcolumn' ? $this->getColumnParameters($call, $table) : []; + + // Parameters that are not a literal array can set any attribute, including the name and change(). + if ($parameters === null) { + return false; + } + + if ($method === 'addcolumn') { + $typeArgument = $parameters['type'] ?? $this->getArgument($call, 0, 'type'); + $nameArgument = $parameters['name'] ?? $this->getArgument($call, 1, 'name'); + $type = $typeArgument === null ? null : $this->resolveName($typeArgument, $table); + $name = $nameArgument === null ? null : $this->resolveName($nameArgument, $table); + } else { + $type = self::COLUMN_METHODS[$method]; + $name = $this->getNameArgument($call, 0, 'column', self::DEFAULT_COLUMN_NAMES[$method] ?? null, $table); + } + + $type = $type === null ? null : strtolower($type); + + if ($name === null) { + return $this->addUnnamedColumn($table, isset($modifiers['change']) || isset($parameters['change'])); + } + + $this->setColumn( + $connection, + $table, + $name, + $this->getTypeModifier($modifiers, $type, $table), + $this->getModifier($modifiers, 'unsigned', match (true) { + in_array($method, self::UNSIGNED_COLUMN_METHODS, true) => true, + in_array($method, self::SIGNABLE_COLUMN_METHODS, true) => $this->getBooleanArgument($call, 2, 'unsigned', false), + isset($parameters['unsigned']) => $this->resolveBoolean($parameters['unsigned']), + default => false, + }), + $this->getModifier($modifiers, 'nullable', isset($parameters['nullable']) + ? $this->resolveBoolean($parameters['nullable']) + : in_array($method, self::NULLABLE_COLUMN_METHODS, true)) ?? true, + in_array($type, ['enum', 'set'], true) && ! isset($modifiers['type']) + ? $this->getAllowedValues($method === 'addcolumn' ? $parameters['allowed'] ?? null : $this->getArgument($call, 1, 'allowed'), $table) + : null, + ); + + return true; + } + + switch ($method) { + case 'foreignidfor': + case 'foreignuuidfor': + case 'foreignulidfor': + $reference = $this->getForeignModel($call, $table); + + if ($reference === null) { + return $this->addUnnamedColumn($table, isset($modifiers['change'])); + } + + [$model, $name] = $reference; + $type = match (true) { + $method === 'foreignuuidfor' => 'uuid', + $method === 'foreignulidfor' => 'char', + $model->getKeyType() === 'int' => 'biginteger', + isset(class_uses_recursive($model)[HasUlids::class]) => 'char', + default => 'uuid', + }; + + $this->setColumn( + $connection, + $table, + $name, + $this->getTypeModifier($modifiers, $type, $table), + $this->getModifier($modifiers, 'unsigned', $type === 'biginteger'), + $this->getModifier($modifiers, 'nullable', false) ?? true, + ); + + return true; + case 'morphs': + case 'nullablemorphs': + case 'numericmorphs': + case 'nullablenumericmorphs': + case 'uuidmorphs': + case 'nullableuuidmorphs': + case 'ulidmorphs': + case 'nullableulidmorphs': + $name = $this->getNameArgument($call, 0, 'name', table: $table); + + if ($name === null) { + return $this->addUnnamedColumn($table, isset($modifiers['change'])); + } + + $keyType = match (true) { + str_contains($method, 'numeric') => 'int', + str_contains($method, 'uuid') => 'uuid', + str_contains($method, 'ulid') => 'ulid', + default => $this->morphKeyType, + }; + $nullable = str_starts_with($method, 'nullable'); + + $this->setColumn($connection, $table, "{$name}_type", 'string', false, $nullable); + $this->setColumn($connection, $table, "{$name}_id", match ($keyType) { + null => null, + 'uuid' => 'uuid', + 'ulid' => 'char', + default => 'biginteger', + }, true, $nullable); + + return true; + case 'timestamps': + case 'nullabletimestamps': + case 'timestampstz': + case 'nullabletimestampstz': + case 'datetimes': + $type = match ($method) { + 'timestampstz', 'nullabletimestampstz' => 'timestamptz', + 'datetimes' => 'datetime', + default => 'timestamp', + }; + + $this->setColumn($connection, $table, 'created_at', $type, false, true); + $this->setColumn($connection, $table, 'updated_at', $type, false, true); + + return true; + case 'renamecolumn': + $from = $this->getNameArgument($call, 0, 'from', table: $table); + $from = $from === null ? null : $this->resolveColumnName($connection, $table, $from); + $to = $this->getNameArgument($call, 1, 'to', table: $table); + + if ($from === null || $to === null) { + return false; + } + + $table->renameColumn($from, $to); + + return true; + case 'after': + $callback = $this->getArgument($call, 1, 'callback'); + $variable = $callback === null ? null : $this->getBlueprintVariable($callback); + + if ($variable === null) { + if ($callback !== null) { + $this->forgetAffectedBy($callback, $scope); + } + + return false; + } + + /** @var PhpParser\Node\Expr\ArrowFunction|PhpParser\Node\Expr\Closure $callback */ + $this->applyBody($this->getUpdateStatements($callback), ['table' => ['variable' => $variable] + $tableScope] + $scope); + + return true; + case 'removecolumn': + $name = $this->getNameArgument($call, 0, 'name', table: $table); + + if ($name === null) { + return false; + } + + // Unlike upstream's drop, it only cancels the column definitions this Blueprint queued, so the + // column may keep its earlier type, or not exist. + $table->setColumn(new SchemaColumn($name, null, true)); + + return true; + case 'drop': + case 'dropifexists': + $this->owner($connection)->dropTable($tableScope['key']); + + return true; + case 'rename': + $this->renameTable($connection, $tableScope['key'], $this->getNameArgument($call, 0, 'to')); + + return true; + case 'dropmorphs': + $name = $this->getNameArgument($call, 0, 'name', table: $table); + $columns = $name === null ? [null] : ["{$name}_type", "{$name}_id"]; + + break; + default: + $columns = match ($method) { + 'dropcolumn' => $this->getColumnNames($call, $table), + 'dropconstrainedforeignid' => [$this->getNameArgument($call, 0, 'column', table: $table)], + 'dropforeignidfor', 'dropconstrainedforeignidfor' => [$this->getForeignModel($call, $table)[1] ?? null], + 'dropremembertoken' => ['remember_token'], + 'dropsoftdeletes', 'dropsoftdeletestz' => [$this->getNameArgument($call, 0, 'column', 'deleted_at', $table)], + 'droptimestamps', 'droptimestampstz' => ['created_at', 'updated_at'], + // Macros and other methods it does not know. + default => [null], + }; + } + + if (in_array(null, $columns, true)) { + return false; + } + + foreach ($columns as $column) { + $column = $this->resolveColumnName($connection, $table, $column); + + if ($column === null) { + return false; + } + + $table->dropColumn($column); + } + + return true; + } + + /** + * Get the parameters addColumn() merges over the column's attributes, or null when they are not a literal + * array with constant keys. + * + * @return null|array + */ + private function getColumnParameters(PhpParser\Node\Expr\MethodCall $call, SchemaTable $table): ?array + { + $argument = $this->getArgument($call, 2, 'parameters'); + + if ($argument === null) { + return []; + } + + if (! $argument instanceof PhpParser\Node\Expr\Array_) { + return null; + } + + $parameters = []; + + foreach ($argument->items as $item) { + $key = $item->unpack || $item->key === null ? null : $this->resolveName($item->key, $table); + + if ($key === null) { + return null; + } + + $parameters[$key] = $item->value; + } + + return $parameters; + } + + /** + * Get the stored name of the column a statement names, or null when several columns could match. + * + * MySQL, MariaDB and SQLite match column names in any letter case, and the column keeps its stored spelling. + */ + private function resolveColumnName(ConnectionConfig $connection, SchemaTable $table, string $name): ?string + { + if (isset($table->columns[$name]) || ! in_array($connection->driver, ['mysql', 'mariadb', 'sqlite'], true)) { + return $name; + } + + $matches = array_values(array_filter( + array_keys($table->columns), + static fn (string $column): bool => mb_strtolower($column) === mb_strtolower($name), + )); + + return match (count($matches)) { + 0 => $name, + 1 => $matches[0], + default => null, + }; + } + + /** + * Record that a table gained columns whose names are unknown, or return false when change() may modify a known one. + * + * Adding a column cannot change the existing ones, so they keep their facts. + */ + private function addUnnamedColumn(SchemaTable $table, bool $change): bool + { + if ($change) { + return false; + } + + $table->complete = false; + + return true; + } + + /** + * Get the value the last nullable() or unsigned() modifier of a chain sets, the default without one, or null when it is not a literal. + * + * @param array $modifiers + */ + private function getModifier(array $modifiers, string $name, ?bool $default): ?bool + { + return isset($modifiers[$name]) ? $this->getBooleanArgument($modifiers[$name], 0, 'value', true) : $default; + } + + /** + * Get the lowercase Blueprint type a type() modifier gives the column, the given type without one, or null + * when the modifier's type is not constant. + * + * @param array $modifiers + */ + private function getTypeModifier(array $modifiers, ?string $type, SchemaTable $table): ?string + { + if (! isset($modifiers['type'])) { + return $type; + } + + $argument = $this->getArgument($modifiers['type'], 0, 'type'); + $name = $argument === null ? null : $this->resolveName($argument, $table); + + return $name === null ? null : strtolower($name); + } + + /** + * Add or replace a column with the type the connection's schema grammar creates for a Blueprint type. + * + * @param ?string $type the lowercase Blueprint type, or null when it is unknown + * @param ?bool $unsigned whether the column is unsigned, or null when it is unknown + * @param null|list $values the values an enum or set allows, or null when they are unknown + */ + private function setColumn( + ConnectionConfig $connection, + SchemaTable $table, + string $name, + ?string $type, + ?bool $unsigned, + bool $nullable, + ?array $values = null, + ): void { + $driver = $connection->driver === 'mariadb' ? 'mysql' : $connection->driver; + $typeName = match (true) { + $connection->driver === 'mariadb' && $type === 'uuid' => 'uuid', + // SQLite's grammar creates json and jsonb columns only when the connection opts in, and text otherwise. + $driver === 'sqlite' && $type === 'json' && $connection->nativeJson => 'json', + $driver === 'sqlite' && $type === 'jsonb' && $connection->nativeJsonb => 'jsonb', + default => self::GRAMMAR_TYPES[$driver][$type ?? ''] ?? null, + }; + // MySQL enums and sets hold their values in the type; the other grammars check an enum's string. + $typeValues = $driver === 'mysql' && in_array($type, ['enum', 'set'], true); + + // Only MySQL reads the sign, so an unknown sign only makes its type unknown. + if ($driver === 'mysql' && $unsigned === null) { + $typeName = null; + } + + $storedName = $this->resolveColumnName($connection, $table, $name); + + // Only change() can name an existing column in another letter case, and the spelling the column + // then keeps depends on the database's statement, so neither spelling is known. + if ($storedName !== $name) { + if ($storedName !== null) { + $table->dropColumn($storedName); + } + + $table->complete = false; + + return; + } + + $table->setColumn(new SchemaColumn( + $name, + $typeName === null ? null : new ColumnType( + $typeName, + $driver === 'pgsql' ? 'pg_catalog' : null, + $type === 'boolean' && $driver !== 'pgsql' ? ['1'] : [], + unsigned: $driver === 'mysql' && $unsigned === true, + values: $typeValues ? $values ?? [] : [], + ), + $nullable, + $type === 'enum' && ! $typeValues ? $values ?? [] : [], + )); + } + + /** + * Get the model a foreignIdFor()-style call references and the column it names, or null when either is unknown. + * + * The model's key and foreign key name come from its class, parents and traits, which become sources of the table's columns. + * + * @return null|array{Model, string} + */ + private function getForeignModel(PhpParser\Node\Expr\MethodCall $call, SchemaTable $table): ?array + { + $modelArgument = $this->getArgument($call, 0, 'model'); + $class = match (true) { + $modelArgument instanceof PhpParser\Node\Expr\ClassConstFetch + && $modelArgument->class instanceof PhpParser\Node\Name + && $modelArgument->name instanceof PhpParser\Node\Identifier + && $modelArgument->name->toLowerString() === 'class' => $modelArgument->class->toString(), + $modelArgument instanceof PhpParser\Node\Scalar\String_ => $modelArgument->value, + default => null, + }; + + if ($class === null || ! $this->reflectionProvider->hasClass($class) || ! $this->reflectionProvider->getClass($class)->is(Model::class)) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($class); + + try { + $model = ModelHelper::newInstanceWithoutConstructor($classReflection); + } catch (ReflectionException) { + return null; + } + + $this->addSources(SourceFiles::forInstance($this->reflectionProvider, $classReflection, Model::class), $table); + + $columnArgument = $this->getArgument($call, 1, 'column'); + $column = $columnArgument === null || $this->isNullLiteral($columnArgument) ? '' : $this->resolveName($columnArgument, $table); + + // Blueprint uses the model's foreign key for an empty name too. + return $column === null ? null : [$model, $column === '' ? $model->getForeignKey() : $column]; + } + + /** + * Get the column names dropColumn() receives as an array or as separate arguments, with null for each one that is not constant. + * + * @return non-empty-list + */ + private function getColumnNames(PhpParser\Node\Expr\MethodCall $call, SchemaTable $table): array + { + $columns = $this->getArgument($call, 0, 'columns'); + $values = $columns instanceof PhpParser\Node\Expr\Array_ + ? array_map(static fn (PhpParser\Node\ArrayItem $item): ?PhpParser\Node\Expr => $item->unpack ? null : $item->value, $columns->items) + : array_map(static fn (PhpParser\Node\Arg $argument): ?PhpParser\Node\Expr => $argument->unpack ? null : $argument->value, $call->getArgs()); + + return array_map(fn (?PhpParser\Node\Expr $value): ?string => $value === null ? null : $this->resolveName($value, $table), $values) ?: [null]; + } + + /** + * Get the values an enum() or set() column allows, or null when they are not constant. + * + * @return null|list + */ + private function getAllowedValues(?PhpParser\Node\Expr $allowed, SchemaTable $table): ?array + { + if (! $allowed instanceof PhpParser\Node\Expr\Array_) { + return null; + } + + $values = []; + + foreach ($allowed->items as $item) { + $value = $item->unpack ? null : $this->resolveName($item->value, $table); + + if ($value === null) { + return null; + } + + $values[] = $value; + } + + return $values; + } + + /** + * Get the constant string an expression names: a literal, or a class constant with a string value. + * + * A constant's files are sources of the table's column facts when a table is given, and of every + * fact otherwise. + */ + private function resolveName(PhpParser\Node\Expr $value, ?SchemaTable $table = null): ?string + { + if ($value instanceof PhpParser\Node\Scalar\String_) { + return $value->value; + } + + if ( + ! $value instanceof PhpParser\Node\Expr\ClassConstFetch + || ! $value->class instanceof PhpParser\Node\Name\FullyQualified + || ! $value->name instanceof PhpParser\Node\Identifier + || ! $this->reflectionProvider->hasClass($value->class->name) + ) { + return null; + } + + $class = $this->reflectionProvider->getClass($value->class->name); + + if ( + ! $class->hasConstant($value->name->toString()) + || ($class->isEnum() && $class->hasEnumCase($value->name->toString())) + ) { + return null; + } + + $constant = $class->getConstant($value->name->toString()); + $strings = $this->initializerExprTypeResolver->getType( + $constant->getValueExpr(), + InitializerExprContext::fromClassReflection($constant->getDeclaringClass()), + )->getConstantStrings(); + + if (count($strings) !== 1) { + return null; + } + + $this->addSources(SourceFiles::forConstant($this->reflectionProvider, $class, $value->name->toString()), $table); + + return $strings[0]->getValue(); + } + + /** + * Record PHP files besides the migrations as sources of a table's column facts, or of every fact without a table. + * + * @param list $files + */ + private function addSources(array $files, ?SchemaTable $table): void + { + foreach ($files as $file) { + if ($table === null) { + $this->sources[$file] = true; + } else { + $table->sources[$file] = true; + } + } + } + + /** + * Apply Schema::drop() or dropIfExists(). + */ + private function dropTable(PhpParser\Node\Expr\CallLike $call, ConnectionConfig $connection): void + { + $owner = $this->owner($connection); + $tableName = $this->getNameArgument($call, 0, 'table'); + + if ($tableName === null) { + $owner->forget(); + + return; + } + + [$schema, $name] = $connection->physicalTable($tableName); + $key = $this->findTableKey($connection, $schema, $name); + $this->forgetCaseVariants($connection, $schema, $name); + + if ($key === false) { + $owner->forget(); + } elseif ($key !== null) { + $owner->dropTable($key); + } + } + + /** + * Apply Schema::rename(). + */ + private function renameTableThroughStaticCall(PhpParser\Node\Expr\CallLike $call, ConnectionConfig $connection): void + { + $from = $this->getNameArgument($call, 0, 'from'); + + if ($from === null) { + $this->owner($connection)->forget(); + + return; + } + + $this->renameTable( + $connection, + $this->resolveTable($connection, ...$connection->physicalTable($from)), + $this->getNameArgument($call, 1, 'to'), + ); + } + + /** + * Move a table's facts to its new name. + * + * PostgreSQL and SQLite rename a table within its schema, while MySQL resolves the new name like + * any other. A new name that is not constant leaves an unknown relation. + */ + private function renameTable(ConnectionConfig $connection, string $key, ?string $to): void + { + $owner = $this->owner($connection); + $table = $owner->tables[$key]; + $owner->dropTable($key); + + if ($to === null) { + $owner->complete = false; + + return; + } + + [$schema, $table->name] = $connection->physicalTable($to); + + if (in_array($connection->driver, ['mysql', 'mariadb'], true)) { + $table->schema = $schema ?? $connection->defaultSchema; + } + + $owner->tables[$owner->key($table->schema, $table->name)] = $table; + } + + /** + * Get the expression a call passes for a parameter, by name or position. + * + * An unpacked argument stands for its position and every later one, so its value is not constant. + */ + private function getArgument(PhpParser\Node\Expr\CallLike $call, int $position, string $parameter): ?PhpParser\Node\Expr + { + if ($call->isFirstClassCallable()) { + return null; + } + + foreach ($call->getArgs() as $index => $argument) { + $matches = $argument->name !== null + ? $argument->name->toString() === $parameter + : $index === $position || ($argument->unpack && $index < $position); + + if ($matches) { + return $argument->value; + } + } + + return null; + } + + /** + * Get the literal boolean a call passes for a parameter, the default when it passes none, or null when it is not a literal. + */ + private function getBooleanArgument(PhpParser\Node\Expr\CallLike $call, int $position, string $parameter, bool $default): ?bool + { + $argument = $this->getArgument($call, $position, $parameter); + + return $argument === null ? $default : $this->resolveBoolean($argument); + } + + /** + * Get the boolean an expression is a literal of, or null when it is not a boolean literal. + */ + private function resolveBoolean(PhpParser\Node\Expr $expr): ?bool + { + return $expr instanceof PhpParser\Node\Expr\ConstFetch && in_array($expr->name->toLowerString(), ['true', 'false'], true) + ? $expr->name->toLowerString() === 'true' + : null; + } + + /** + * Get the constant name a call passes for a parameter, the default when it passes none, or null when it is not constant. + */ + private function getNameArgument(PhpParser\Node\Expr\CallLike $call, int $position, string $parameter, ?string $default = null, ?SchemaTable $table = null): ?string + { + $argument = $this->getArgument($call, $position, $parameter); + + return $argument === null ? $default : $this->resolveName($argument, $table); + } + + /** + * Determine whether an expression is the null literal. + */ + private function isNullLiteral(PhpParser\Node\Expr $expr): bool + { + return $expr instanceof PhpParser\Node\Expr\ConstFetch && $expr->name->toLowerString() === 'null'; + } + + /** + * Get the statements a callback runs. + * + * Unlike upstream, which applied the expressions inside if statements unconditionally, branches + * are applied as branches. + * + * @return PhpParser\Node\Stmt[] + */ + private function getUpdateStatements(PhpParser\Node\Expr\Closure|PhpParser\Node\Expr\ArrowFunction $callback): array + { + return $callback instanceof PhpParser\Node\Expr\ArrowFunction + ? [new PhpParser\Node\Stmt\Expression($callback->expr)] + : $callback->stmts; + } + + /** + * Get the connection and SQL of a statement() or unprepared() call, or null when the expression is not one. + * + * The call runs on the default connection through the DB facade, or on a selected one through + * DB::connection() or Schema's getConnection(). The connection is null when it is unknown, and + * the SQL when it is not a literal, as for a first-class callable. + * + * @param Scope $scope + * @return null|array{?ConnectionConfig, ?string} + */ + private function literalDatabaseStatement(PhpParser\Node\Expr $expr, array $scope): ?array + { + if (! ($expr instanceof PhpParser\Node\Expr\StaticCall || $expr instanceof PhpParser\Node\Expr\MethodCall) + || ! $expr->name instanceof PhpParser\Node\Identifier + || ! in_array($expr->name->toLowerString(), ['statement', 'unprepared'], true)) { + return null; + } + + if ($expr instanceof PhpParser\Node\Expr\StaticCall) { + if (! $this->isFacade($expr->class, DB::class)) { + return null; + } + + $connection = $scope['connection']; + } elseif (($schemaCall = $this->getSchemaCall($expr->var, $scope)) !== null && $schemaCall['method'] === 'getconnection') { + $connection = $schemaCall['connection']; + } elseif ($this->isFacadeCall($expr->var, DB::class, 'connection')) { + $connection = $this->getNamedConnection($expr->var, $scope); + } else { + return null; + } + + $sql = $this->getArgument($expr, 0, 'query'); + + return [$connection, $sql instanceof PhpParser\Node\Scalar\String_ ? $sql->value : null]; + } + + /** + * Determine whether a class name refers to a facade, also through its global alias. + */ + private function isFacade(PhpParser\Node $class, string $facade): bool + { + return $class instanceof PhpParser\Node\Name + && ($class->toString() === class_basename($facade) || $this->isClass($class, $facade)); + } + + /** + * Determine whether an expression calls a facade method. + * + * @param lowercase-string $method + * @phpstan-assert-if-true =PhpParser\Node\Expr\StaticCall $expr + */ + private function isFacadeCall(PhpParser\Node $expr, string $facade, string $method): bool + { + return $expr instanceof PhpParser\Node\Expr\StaticCall + && $expr->name instanceof PhpParser\Node\Identifier + && $expr->name->toLowerString() === $method + && $this->isFacade($expr->class, $facade); + } + + /** + * Determine whether a class name refers to a class or one of its subclasses. + */ + private function isClass(PhpParser\Node\Name $name, string $class): bool + { + return $this->reflectionProvider->hasClass($name->toString()) + && $this->reflectionProvider->getClass($name->toString())->is($class); + } + + /** + * Apply raw SQL to the facts of its connection. + * + * Each parse starts from an empty schema, so its definitions only describe the relations and types + * the SQL creates, and its impact names the earlier relations and types it changes. A definition of + * a relation that may already exist could be a no-op, such as CREATE TABLE IF NOT EXISTS, so it + * makes the relation unknown. Session state that separate calls carry, such as USE or SET + * search_path, is not tracked: each call resolves names with the connection's configured + * database and search path. + */ + private function applyLiteralSql(ConnectionConfig $connection, string $sql): void + { + $owner = $this->owner($connection); + + try { + $schema = $connection->createParser()?->parse($sql); + } catch (ParseException) { + $schema = null; + } + + if ($schema === null || $schema->impact->unknown) { + $owner->forget(); + + return; + } + + foreach ($schema->impact->relations as $relation) { + $this->forgetTableByKey($connection, $this->resolveTable($connection, $relation->schema, $relation->name)); + } + + foreach ($schema->impact->types as $type) { + $owner->forgetType($type->schema, $type->name, $connection->searchPath); + } + + foreach ([...$schema->tables, ...$schema->views] as $definition) { + $exists = ! $owner->complete + || $this->findTableKey($connection, $definition->schema, $definition->name) !== null + || $owner->caseVariants($definition->schema ?? $connection->defaultSchema, $definition->name) !== []; + + if ($definition instanceof View || $exists) { + $this->forgetTableByKey($connection, $this->resolveTable($connection, $definition->schema, $definition->name)); + + continue; + } + + $owner->addTable($definition, $connection, $schema->issues === []); + } + + // A type definition with an issue could have other labels or a different base. + foreach ([...$schema->enums, ...$schema->domains] as $type) { + $owner->types[$owner->key($type->schema, $type->name)] = $schema->issues === [] ? $type : null; + } + } +} diff --git a/src/phpstan/src/Properties/SchemaColumn.php b/src/phpstan/src/Properties/SchemaColumn.php new file mode 100644 index 0000000000..cbbddfaaa5 --- /dev/null +++ b/src/phpstan/src/Properties/SchemaColumn.php @@ -0,0 +1,28 @@ + $options the values a CHECK constraint from Blueprint's enum() allows + */ + public function __construct( + public string $name, + public ?ColumnType $type, + public bool $nullable = false, + public array $options = [], + ) { + } +} diff --git a/src/phpstan/src/Properties/SchemaConnection.php b/src/phpstan/src/Properties/SchemaConnection.php new file mode 100644 index 0000000000..87800e2824 --- /dev/null +++ b/src/phpstan/src/Properties/SchemaConnection.php @@ -0,0 +1,244 @@ + tables and views, by identity key */ + public array $tables = []; + + /** @var array PostgreSQL user types by identity key, null when the definition or its presence is uncertain */ + public array $types = []; + + /** Whether every relation is known, so that a missing table is a claim. */ + public bool $complete = true; + + /** @var list the schema dumps that could not be read */ + public array $unusableDumps = []; + + /** + * Create a new schema connection instance. + */ + public function __construct(public string $name, public string $driver) + { + } + + /** + * Copy the tables, so that each branch of a migration changes its own copy. + */ + public function __clone(): void + { + foreach ($this->tables as $key => $table) { + $this->tables[$key] = clone $table; + } + } + + /** + * Get the identity key of a schema-qualified name, which ignores ASCII case where SQLite does. + */ + public function key(?string $schema, string $name): string + { + if ($this->driver === 'sqlite') { + $schema = $schema === null ? null : strtolower($schema); + $name = strtolower($name); + } + + return ($schema === null ? 'n:' : 's:' . strlen($schema) . ':' . $schema) . $name; + } + + /** + * Find the identity key of a relation, or return null when it is absent and false when it is ambiguous. + * + * Unqualified MySQL and SQLite names use the connection's default schema. An unqualified PostgreSQL + * name resolves through the search path. An unqualified definition was created in the first existing + * schema of its connection's path, so it only matches the same path when no qualified candidate exists. + * + * @param list $searchPath + */ + public function findTableKey(?string $schema, string $name, ?string $defaultSchema, array $searchPath): false|string|null + { + if ($this->driver !== 'pgsql') { + $key = $this->key($schema ?? $defaultSchema, $name); + + return isset($this->tables[$key]) ? $key : null; + } + + $unqualifiedKey = $this->key(null, $name); + $unqualified = $this->tables[$unqualifiedKey] ?? null; + + if ($schema !== null) { + $key = $this->key($schema, $name); + + return $unqualified !== null ? false : (isset($this->tables[$key]) ? $key : null); + } + + foreach ($searchPath as $searchSchema) { + $key = $this->key($searchSchema, $name); + + if (isset($this->tables[$key])) { + return $unqualified === null ? $key : false; + } + } + + if ($unqualified === null) { + return null; + } + + return $unqualified->searchPath === $searchPath ? $unqualifiedKey : false; + } + + /** + * Add a parsed table, linking the known relations it inherits from or partitions. + * + * @param bool $complete whether the input it comes from is free of schema-level issues + */ + public function addTable(Table $definition, ConnectionConfig $connection, bool $complete): void + { + $table = new SchemaTable($definition->name, $definition->schema ?? $connection->definitionSchema()); + $table->searchPath = $table->schema === null ? $connection->searchPath : null; + $table->complete = $complete && $definition->isComplete(); + + foreach ($definition->columns as $column) { + $table->setColumn(new SchemaColumn($column->name, $column->type, $column->nullable)); + } + + foreach ([...$definition->parents, ...($definition->partitionOf === null ? [] : [$definition->partitionOf])] as $parent) { + $key = $this->findTableKey($parent->schema, $parent->name, $connection->defaultSchema, $connection->searchPath); + + if (is_string($key)) { + $table->parents[] = $key; + } + } + + $this->tables[$this->key($table->schema, $table->name)] = $table; + } + + /** + * Get the identity keys of other relations whose schema and name differ only in letter case. + * + * MySQL compares database and table names without case on some servers, so a change to one + * spelling can change another. Identities stay exact, since other servers keep them distinct. + * + * @return list + */ + public function caseVariants(?string $schema, string $name): array + { + if (! in_array($this->driver, ['mysql', 'mariadb'], true)) { + return []; + } + + $exact = $this->key($schema, $name); + $folded = $this->key($schema === null ? null : mb_strtolower($schema), mb_strtolower($name)); + $variants = []; + + foreach ($this->tables as $key => $table) { + if ($key !== $exact && $this->key($table->schema === null ? null : mb_strtolower($table->schema), mb_strtolower($table->name)) === $folded) { + $variants[] = $key; + } + } + + return $variants; + } + + /** + * Make a relation and its known inheritance and partition descendants unknown. + * + * The relation may no longer exist or have its earlier columns. PostgreSQL applies + * ALTER TABLE without ONLY, and the drop of a partitioned table, to descendants too. + */ + public function forgetTable(?string $schema, string $name): void + { + $key = $this->key($schema, $name); + $table = $this->tables[$key] ??= new SchemaTable($name, $schema); + $table->columns = []; + $table->complete = false; + + $this->forgetDescendants($key); + } + + /** + * Make the known inheritance and partition descendants of a relation unknown. + */ + public function forgetDescendants(string $key): void + { + $forgotten = [$key]; + + for ($index = 0; isset($forgotten[$index]); ++$index) { + foreach ($this->tables as $childKey => $child) { + if (in_array($forgotten[$index], $child->parents, true) && ! in_array($childKey, $forgotten, true)) { + $child->columns = []; + $child->complete = false; + $forgotten[] = $childKey; + } + } + } + } + + /** + * Remove a relation that no longer exists, making its descendants unknown. + */ + public function dropTable(string $key): void + { + $this->forgetDescendants($key); + + unset($this->tables[$key]); + } + + /** + * Make a user type uncertain after a script altered or dropped it. + * + * A qualified name is marked even when nothing recorded it, which stops any search along + * a path through its schema, so a definition recorded without a schema stays known: it was + * created in a path schema. An unqualified name can denote any recorded definition along + * the search path. + * + * @param list $searchPath + */ + public function forgetType(?string $schema, string $name, array $searchPath): void + { + if ($schema !== null) { + $this->types[$this->key($schema, $name)] = null; + + return; + } + + foreach ([null, ...$searchPath] as $candidate) { + $key = $this->key($candidate, $name); + + if (array_key_exists($key, $this->types)) { + $this->types[$key] = null; + } + } + } + + /** + * Make every fact unknown. + */ + public function forget(): void + { + $this->tables = []; + $this->types = []; + $this->complete = false; + } +} diff --git a/src/phpstan/src/Properties/SchemaTable.php b/src/phpstan/src/Properties/SchemaTable.php new file mode 100644 index 0000000000..b100a2cc5a --- /dev/null +++ b/src/phpstan/src/Properties/SchemaTable.php @@ -0,0 +1,69 @@ + */ + public array $columns = []; + + /** Whether every column is known, so that a missing column is a claim. */ + public bool $complete = true; + + /** @var list the identity keys of the tables it inherits from or partitions */ + public array $parents = []; + + /** @var null|list the search path of the connection that defined it without a schema */ + public ?array $searchPath = null; + + /** @var array the PHP files besides the migrations that its columns were read from, such as a constant naming one */ + public array $sources = []; + + /** + * Create a new schema table instance. + */ + public function __construct(public string $name, public ?string $schema = null) + { + } + + /** + * Add or replace a column. + */ + public function setColumn(SchemaColumn $column): void + { + $this->columns[$column->name] = $column; + } + + /** + * Rename a column. + */ + public function renameColumn(string $oldName, string $newName): void + { + // An unknown column of an incomplete table keeps an unknown type under its new name. + if (! isset($this->columns[$oldName])) { + if (! $this->complete) { + $this->setColumn(new SchemaColumn($newName, null, true)); + } + + return; + } + + $oldColumn = $this->columns[$oldName]; + + unset($this->columns[$oldName]); + + // Branch copies of a table share their columns, so the renamed column is a new instance. + $this->columns[$newName] = new SchemaColumn($newName, $oldColumn->type, $oldColumn->nullable, $oldColumn->options); + } + + /** + * Drop a column. + */ + public function dropColumn(string $columnName): void + { + unset($this->columns[$columnName]); + } +} diff --git a/src/phpstan/src/Properties/SquashedMigrationHelper.php b/src/phpstan/src/Properties/SquashedMigrationHelper.php new file mode 100644 index 0000000000..215969dbb7 --- /dev/null +++ b/src/phpstan/src/Properties/SquashedMigrationHelper.php @@ -0,0 +1,190 @@ + + */ + public function initializeConnections(): array + { + $connections = []; + $uninterpreted = []; + + foreach ($this->findSchemaFiles() as [$name, $file]) { + $connection = $this->connectionConfigResolver->connection($name); + + if ($connection !== null + && ! $this->addDump($connections[$connection->owner] ??= new SchemaConnection($connection->owner, $connection->driver), $connection, $file)) { + $uninterpreted[$connection->owner] = true; + } + } + + // A dump that could not be read may define any relation the owner's other dumps define. + foreach (array_keys($uninterpreted) as $owner) { + $connections[$owner]->forget(); + } + + return $connections; + } + + /** + * Add a schema dump's definitions to its owner's facts, or return false when the dump cannot be interpreted. + * + * Schema-level issues mean represented definitions may be wrong, so its tables are incomplete + * and its types uncertain. Another dump defining the same relation, or a type differently, + * makes it unknown. + */ + private function addDump(SchemaConnection $owner, ConnectionConfig $connection, SplFileInfo $file): bool + { + $sql = file_get_contents($file->getPathname()); + $schema = null; + $failure = $sql === false ? new ParseException('The dump could not be read') : null; + + if ($sql !== false) { + try { + $schema = $connection->createParser()?->parse($sql); + } catch (ParseException $exception) { + $failure = $exception; + } + } + + if ($failure !== null) { + $owner->unusableDumps[] = ['path' => $file->getPathname(), 'line' => $failure->sqlLine ?? 1, 'message' => $failure->getMessage()]; + } + + if ($schema === null) { + return false; + } + + $complete = $schema->issues === []; + $owner->complete = $owner->complete && $complete; + $altered = []; + + foreach ($schema->impact->relations as $relation) { + $altered[$owner->key($relation->schema ?? $connection->definitionSchema(), $relation->name)] = true; + } + + foreach ($schema->tables as $definition) { + $tableSchema = $definition->schema ?? $connection->definitionSchema(); + $key = $owner->key($tableSchema, $definition->name); + + // Dumps alter most tables they create, but an alteration the parser could not apply can leave a + // column that no longer exists or has another type. + if (isset($owner->tables[$key]) || (! $definition->isComplete() && isset($altered[$key]))) { + $owner->forgetTable($tableSchema, $definition->name); + } else { + $owner->addTable($definition, $connection, $complete); + } + } + + foreach ($schema->views as $view) { + $owner->forgetTable($view->schema ?? $connection->definitionSchema(), $view->name); + } + + foreach ([...$schema->enums, ...$schema->domains] as $type) { + $key = $owner->key($type->schema, $type->name); + $owner->types[$key] = ! $complete || (array_key_exists($key, $owner->types) && $owner->types[$key] != $type) ? null : $type; + } + + return true; + } + + /** + * Get the schema dumps of the booted connections, which the migration cache fingerprints. + * + * @return list + */ + public function getSchemaFiles(): array + { + return array_column($this->findSchemaFiles(), 1); + } + + /** + * Find each booted connection's schema dump in each schema directory, with the connection's name. + * + * As in MigrateCommand::schemaPath(), a connection's .dump file takes precedence over its .sql file. + * + * @return list + */ + private function findSchemaFiles(): array + { + $files = []; + + foreach ($this->getSchemaDirectories() as $directory) { + foreach ($this->connectionConfigResolver->getConnectionNames() as $name) { + foreach (['dump', 'sql'] as $extension) { + $path = "{$directory}/{$name}-schema.{$extension}"; + + if (is_file($path)) { + $files[] = [$name, new SplFileInfo($path)]; + + break; + } + } + } + } + + return $files; + } + + /** + * The directories scanned for schema dumps, see DirectoryResolver::resolve(). + * + * @return list + */ + public function getSchemaDirectories(): array + { + if ($this->disableSchemaScan) { + return []; + } + + if (empty($this->schemaPaths)) { + $this->schemaPaths = [database_path('schema')]; + } + + return DirectoryResolver::resolve($this->schemaPaths, $this->fileHelper); + } +} diff --git a/src/phpstan/src/Reflection/AttributeClassNameResolver.php b/src/phpstan/src/Reflection/AttributeClassNameResolver.php new file mode 100644 index 0000000000..796dc97240 --- /dev/null +++ b/src/phpstan/src/Reflection/AttributeClassNameResolver.php @@ -0,0 +1,60 @@ +class instanceof Name + && ! $argument->class->isSpecialClassName() + && $argument->name instanceof Identifier + && $argument->name->toLowerString() !== 'class') { + $this->declarationDependencyTracker->trackClassDependency($dependentReflection, $argument->class->toString()); + } + + $classNames = $this->initializerExprTypeResolver + ->getType($argument, InitializerExprContext::fromClassReflection($sourceReflection)) + ->getConstantStrings(); + + return count($classNames) === 1 ? ltrim($classNames[0]->getValue(), '\\') : null; + } +} diff --git a/src/phpstan/src/Reflection/CallableParameterReflection.php b/src/phpstan/src/Reflection/CallableParameterReflection.php new file mode 100644 index 0000000000..aa7a8ef7a3 --- /dev/null +++ b/src/phpstan/src/Reflection/CallableParameterReflection.php @@ -0,0 +1,74 @@ +name; + } + + /** + * Determine whether the parameter is optional. + */ + public function isOptional(): bool + { + return false; + } + + /** + * Return the parameter type. + */ + public function getType(): Type + { + return $this->type; + } + + /** + * Return the parameter reference mode. + */ + public function passedByReference(): PassedByReference + { + return PassedByReference::createNo(); + } + + /** + * Determine whether the parameter is variadic. + */ + public function isVariadic(): bool + { + return false; + } + + /** + * Return the default value type. + */ + public function getDefaultValue(): ?Type + { + return null; + } +} diff --git a/src/database/src/PHPStan/ForwardedModelMethodReflection.php b/src/phpstan/src/Reflection/DynamicRelationMethodReflection.php similarity index 62% rename from src/database/src/PHPStan/ForwardedModelMethodReflection.php rename to src/phpstan/src/Reflection/DynamicRelationMethodReflection.php index e124e626d8..2e0f2a795c 100644 --- a/src/database/src/PHPStan/ForwardedModelMethodReflection.php +++ b/src/phpstan/src/Reflection/DynamicRelationMethodReflection.php @@ -2,37 +2,33 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Reflection; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\FunctionVariant; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParametersAcceptor; use PHPStan\TrinaryLogic; +use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Type; /** - * Describe a builder method forwarded through an Eloquent model. + * Describe a relation method that a __call() handles without arguments, such as a relation that + * resolveRelationUsing() registers or a model's through{Relation}(). */ -class ForwardedModelMethodReflection implements MethodReflection +class DynamicRelationMethodReflection implements MethodReflection { /** - * Create a forwarded model method reflection. + * Create a dynamic relation method reflection. */ public function __construct( private readonly ClassReflection $declaringClass, - private readonly MethodReflection $forwardedMethod, + private readonly string $methodName, + private readonly Type $returnType, + private readonly bool $isStatic, ) { } - /** - * Return the method name. - */ - public function getName(): string - { - return $this->forwardedMethod->getName(); - } - /** * Return the declaring class. */ @@ -46,7 +42,7 @@ public function getDeclaringClass(): ClassReflection */ public function isStatic(): bool { - return true; + return $this->isStatic; } /** @@ -66,11 +62,11 @@ public function isPublic(): bool } /** - * Return the method docblock. + * Return the method name. */ - public function getDocComment(): ?string + public function getName(): string { - return $this->forwardedMethod->getDocComment(); + return $this->methodName; } /** @@ -82,13 +78,29 @@ public function getPrototype(): ClassMemberReflection } /** - * Return the forwarded builder method variants. + * Return the method's single variant, which takes no arguments. * - * @return list + * @return list */ public function getVariants(): array { - return $this->forwardedMethod->getVariants(); + return [ + new FunctionVariant( + TemplateTypeMap::createEmpty(), + null, + [], + false, + $this->returnType, + ), + ]; + } + + /** + * Return no docblock for the synthesized signature. + */ + public function getDocComment(): ?string + { + return null; } /** @@ -96,7 +108,7 @@ public function getVariants(): array */ public function isDeprecated(): TrinaryLogic { - return $this->forwardedMethod->isDeprecated(); + return TrinaryLogic::createNo(); } /** @@ -104,7 +116,7 @@ public function isDeprecated(): TrinaryLogic */ public function getDeprecatedDescription(): ?string { - return $this->forwardedMethod->getDeprecatedDescription(); + return null; } /** @@ -112,7 +124,7 @@ public function getDeprecatedDescription(): ?string */ public function isFinal(): TrinaryLogic { - return $this->forwardedMethod->isFinal(); + return TrinaryLogic::createNo(); } /** @@ -120,7 +132,7 @@ public function isFinal(): TrinaryLogic */ public function isInternal(): TrinaryLogic { - return $this->forwardedMethod->isInternal(); + return TrinaryLogic::createNo(); } /** @@ -128,7 +140,7 @@ public function isInternal(): TrinaryLogic */ public function getThrowType(): ?Type { - return $this->forwardedMethod->getThrowType(); + return null; } /** @@ -136,6 +148,6 @@ public function getThrowType(): ?Type */ public function hasSideEffects(): TrinaryLogic { - return $this->forwardedMethod->hasSideEffects(); + return TrinaryLogic::createMaybe(); } } diff --git a/src/phpstan/src/Reflection/DynamicWhereMethodReflection.php b/src/phpstan/src/Reflection/DynamicWhereMethodReflection.php new file mode 100644 index 0000000000..8e37c449d7 --- /dev/null +++ b/src/phpstan/src/Reflection/DynamicWhereMethodReflection.php @@ -0,0 +1,201 @@ +classReflection; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Get the method's doc comment. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Get the method name. + */ + public function getName(): string + { + return $this->methodName; + } + + /** + * Get the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Get the method's single variant, which accepts one value and returns the object. + * + * @return FunctionVariant[] + */ + public function getVariants(): array + { + return [ + new FunctionVariant( + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + [ + new class implements ParameterReflection { + /** + * Get the parameter name. + */ + public function getName(): string + { + return 'dynamic-with'; + } + + /** + * Determine whether the parameter is optional. + */ + public function isOptional(): bool + { + return false; + } + + /** + * Get the parameter type. + */ + public function getType(): Type + { + return new MixedType; + } + + /** + * Determine how the parameter is passed. + */ + public function passedByReference(): PassedByReference + { + return PassedByReference::createNo(); + } + + /** + * Determine whether the parameter is variadic. + */ + public function isVariadic(): bool + { + return false; + } + + /** + * Get the parameter's default value. + */ + public function getDefaultValue(): ?Type + { + return null; + } + }, + ], + false, + new ObjectType($this->classReflection->getName()), + ), + ]; + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Get the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return null; + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Get the type the method throws. + */ + public function getThrowType(): ?Type + { + return null; + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + // Upstream reports none, which makes PHPStan call an unused $response->withFoo() + // statement useless, but the call stores the value on the response or view. + return TrinaryLogic::createYes(); + } +} diff --git a/src/phpstan/src/Reflection/DynamicWhereParameterReflection.php b/src/phpstan/src/Reflection/DynamicWhereParameterReflection.php new file mode 100644 index 0000000000..5eb4d6af86 --- /dev/null +++ b/src/phpstan/src/Reflection/DynamicWhereParameterReflection.php @@ -0,0 +1,61 @@ + $parameters + */ + public function __construct( + private string $methodName, + private ClassReflection $classReflection, + private array $parameters, + ?Type $returnType = null, + private bool $isVariadic = false, + ) { + $this->returnType = $returnType ?? new ObjectType(Builder::class); + } + + /** + * Return the declaring class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->classReflection; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Return the method name. + */ + public function getName(): string + { + return $this->methodName; + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return the method variants. + * + * @return list + */ + public function getVariants(): array + { + return [ + new FunctionVariant( + TemplateTypeMap::createEmpty(), + null, + $this->parameters, + $this->isVariadic, + $this->returnType, + ), + ]; + } + + /** + * Return no docblock for the synthesized signature. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Return the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return null; + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Return the declared throw type. + */ + public function getThrowType(): ?Type + { + return null; + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } +} diff --git a/src/phpstan/src/Reflection/ForwardedFluentMethodReflection.php b/src/phpstan/src/Reflection/ForwardedFluentMethodReflection.php new file mode 100644 index 0000000000..9cf698c48c --- /dev/null +++ b/src/phpstan/src/Reflection/ForwardedFluentMethodReflection.php @@ -0,0 +1,55 @@ + + */ + public function getVariants(): array + { + return array_map( + fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + ProxiedParameterReflection::fromTargetParameters($variant->getParameters()), + $variant->isVariadic(), + $this->mayReturnForwardedResult + ? TypeCombinator::union(new ThisType($this->declaringClass), $variant->getReturnType()) + : new ThisType($this->declaringClass), + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ), + $this->forwardedMethod->getVariants(), + ); + } +} diff --git a/src/database/src/PHPStan/ForwardedFluentMethodReflection.php b/src/phpstan/src/Reflection/ForwardedMethodReflection.php similarity index 66% rename from src/database/src/PHPStan/ForwardedFluentMethodReflection.php rename to src/phpstan/src/Reflection/ForwardedMethodReflection.php index 835b3ed7fc..24be5d8d81 100644 --- a/src/database/src/PHPStan/ForwardedFluentMethodReflection.php +++ b/src/phpstan/src/Reflection/ForwardedMethodReflection.php @@ -2,29 +2,34 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Reflection; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\FunctionVariant; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\TrinaryLogic; -use PHPStan\Type\ThisType; use PHPStan\Type\Type; +use function array_all; +use function array_map; + /** - * Describe a forwarded fluent method whose runtime result is the receiving object. + * Describe a method that another object handles for the receiver. + * + * The receiver is the declaring class, so PHPStan binds the receiver's template types in the forwarded signature. */ -class ForwardedFluentMethodReflection implements MethodReflection +class ForwardedMethodReflection implements MethodReflection { /** - * Create a forwarded fluent method reflection. + * Create a forwarded method reflection. */ public function __construct( - private readonly ClassReflection $declaringClass, - private readonly MethodReflection $forwardedMethod, + protected readonly ClassReflection $declaringClass, + protected readonly MethodReflection $forwardedMethod, ) { } @@ -44,12 +49,22 @@ public function getDeclaringClass(): ClassReflection return $this->declaringClass; } + /** + * Return the method of the object that handles the call. + */ + public function getForwardedMethod(): MethodReflection + { + return $this->forwardedMethod; + } + /** * Determine whether the method is static. */ public function isStatic(): bool { - return $this->forwardedMethod->isStatic(); + // The receiver's __call() forwards it, so a static method of the other object is not static here: + // Builder::__callStatic(), for one, runs only the builder's own global macros. + return false; } /** @@ -85,19 +100,25 @@ public function getPrototype(): ClassMemberReflection } /** - * Return the callable variants with the receiving object as their result. + * Return the forwarded method variants. + * + * The receiver's __call() passes the argument values on, so a reference parameter never gets + * the caller's variable. A variant without one stays as it is, keeping PHPStan's extended metadata. * * @return list */ public function getVariants(): array { return array_map( - fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( + static fn (ParametersAcceptor $variant): ParametersAcceptor => array_all( + $variant->getParameters(), + static fn (ParameterReflection $parameter): bool => $parameter->passedByReference()->no(), + ) ? $variant : new FunctionVariant( $variant->getTemplateTypeMap(), $variant->getResolvedTemplateTypeMap(), - $variant->getParameters(), + ProxiedParameterReflection::fromTargetParameters($variant->getParameters()), $variant->isVariadic(), - new ThisType($this->declaringClass), + $variant->getReturnType(), $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, ), $this->forwardedMethod->getVariants(), diff --git a/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php b/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php new file mode 100644 index 0000000000..3cb0f25745 --- /dev/null +++ b/src/phpstan/src/Reflection/ForwardedModelMethodReflection.php @@ -0,0 +1,81 @@ + + */ + public function getVariants(): array + { + if ($this->returnType === null) { + return parent::getVariants(); + } + + return array_map( + fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + $variant->getParameters(), + $variant->isVariadic(), + $this->returnType, + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ), + parent::getVariants(), + ); + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return true; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return true; + } +} diff --git a/src/phpstan/src/Reflection/MacroRegistrationMethodReflection.php b/src/phpstan/src/Reflection/MacroRegistrationMethodReflection.php new file mode 100644 index 0000000000..be46022faa --- /dev/null +++ b/src/phpstan/src/Reflection/MacroRegistrationMethodReflection.php @@ -0,0 +1,52 @@ + + */ + public function getVariants(): array + { + return array_map( + static fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + array_map( + static fn (ParameterReflection $parameter): ParameterReflection => new ProxiedParameterReflection($parameter), + $variant->getParameters(), + ), + $variant->isVariadic(), + $variant->getReturnType(), + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ), + $this->forwardedMethod->getVariants(), + ); + } +} diff --git a/src/database/src/PHPStan/ModelScopeParameterReflection.php b/src/phpstan/src/Reflection/ModelScopeParameterReflection.php similarity index 87% rename from src/database/src/PHPStan/ModelScopeParameterReflection.php rename to src/phpstan/src/Reflection/ModelScopeParameterReflection.php index fdda0520c2..06fa577ec4 100644 --- a/src/database/src/PHPStan/ModelScopeParameterReflection.php +++ b/src/phpstan/src/Reflection/ModelScopeParameterReflection.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Reflection; +use Hypervel\PHPStan\Methods\ModelScopeTypeResolver; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\PassedByReference; @@ -52,7 +53,8 @@ public function getType(): Type */ public function passedByReference(): PassedByReference { - return $this->parameter->passedByReference(); + // A scope is called with the argument values __call() received, never the caller's variables. + return PassedByReference::createNo(); } /** diff --git a/src/database/src/PHPStan/NamedScopeMethodReflection.php b/src/phpstan/src/Reflection/NamedScopeMethodReflection.php similarity index 98% rename from src/database/src/PHPStan/NamedScopeMethodReflection.php rename to src/phpstan/src/Reflection/NamedScopeMethodReflection.php index c954264593..cc697c91a8 100644 --- a/src/database/src/PHPStan/NamedScopeMethodReflection.php +++ b/src/phpstan/src/Reflection/NamedScopeMethodReflection.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Database\PHPStan; +namespace Hypervel\PHPStan\Reflection; +use Hypervel\PHPStan\Methods\ModelScopeTypeResolver; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; diff --git a/src/phpstan/src/Reflection/ProxiedMethodReflection.php b/src/phpstan/src/Reflection/ProxiedMethodReflection.php new file mode 100644 index 0000000000..f7964a38b2 --- /dev/null +++ b/src/phpstan/src/Reflection/ProxiedMethodReflection.php @@ -0,0 +1,184 @@ +targetMethod->getName(); + } + + /** + * Return the proxy class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Return the method docblock. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return the target method's variants with the proxy's parameters and return types. + * + * @return list + */ + public function getVariants(): array + { + return array_map( + fn (ParametersAcceptor $variant): FunctionVariant => self::proxyVariant( + $variant, + ($this->returnType)($variant->getReturnType()), + ), + $this->targetMethod->getVariants(), + ); + } + + /** + * Describe a target method's variant as a proxy receives it, returning the given type. + */ + public static function proxyVariant(ParametersAcceptor $variant, Type $returnType): FunctionVariant + { + return new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + ProxiedParameterReflection::fromTargetParameters($variant->getParameters()), + $variant->isVariadic(), + self::resolveStaticTypes($returnType), + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ); + } + + /** + * Resolve the static types in a type the proxy returns to their classes. PHPStan binds the + * static types of a proxy's members to the proxy, while they describe the proxy's target. + */ + public static function resolveStaticTypes(Type $type): Type + { + return TypeTraverser::map( + $type, + static fn (Type $type, callable $traverse): Type => $type instanceof StaticType ? $type->getStaticObjectType() : $traverse($type), + ); + } + + /** + * Determine whether the target method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->targetMethod->isDeprecated(); + } + + /** + * Return the target method's deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->targetMethod->getDeprecatedDescription(); + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + /** + * Determine whether the target method is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->targetMethod->isInternal(); + } + + /** + * Return the target method's throw type. + */ + public function getThrowType(): ?Type + { + return $this->targetMethod->getThrowType(); + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } +} diff --git a/src/phpstan/src/Reflection/ProxiedParameterReflection.php b/src/phpstan/src/Reflection/ProxiedParameterReflection.php new file mode 100644 index 0000000000..d9be42aea7 --- /dev/null +++ b/src/phpstan/src/Reflection/ProxiedParameterReflection.php @@ -0,0 +1,93 @@ + $parameters + * @return list + */ + public static function fromTargetParameters(array $parameters): array + { + return array_map( + static fn (ParameterReflection $parameter): ParameterReflection => $parameter->passedByReference()->no() + ? $parameter + : new self($parameter), + $parameters, + ); + } + + /** + * Return the parameter name. + */ + public function getName(): string + { + return $this->parameter->getName(); + } + + /** + * Determine whether the parameter is optional. + */ + public function isOptional(): bool + { + return $this->parameter->isOptional(); + } + + /** + * Return the parameter type. + */ + public function getType(): Type + { + return $this->parameter->getType(); + } + + /** + * Return the parameter reference mode. + */ + public function passedByReference(): PassedByReference + { + return PassedByReference::createNo(); + } + + /** + * Determine whether the parameter is variadic. + */ + public function isVariadic(): bool + { + return $this->parameter->isVariadic(); + } + + /** + * Return the default value type. + */ + public function getDefaultValue(): ?Type + { + return $this->parameter->getDefaultValue(); + } +} diff --git a/src/phpstan/src/Reflection/ProxiedPropertyReflection.php b/src/phpstan/src/Reflection/ProxiedPropertyReflection.php new file mode 100644 index 0000000000..8945c86fe3 --- /dev/null +++ b/src/phpstan/src/Reflection/ProxiedPropertyReflection.php @@ -0,0 +1,139 @@ +type = ProxiedMethodReflection::resolveStaticTypes($type); + } + + /** + * Return the proxy class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + /** + * Determine whether the property is static. + */ + public function isStatic(): bool + { + return false; + } + + /** + * Determine whether the property is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the property is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Return the property docblock. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Get the type read from the property. + */ + public function getReadableType(): Type + { + return $this->type; + } + + /** + * Get the type written to the property. + */ + public function getWritableType(): Type + { + return $this->type; + } + + /** + * Determine whether an assignment can change the property's type. + */ + public function canChangeTypeAfterAssignment(): bool + { + return false; + } + + /** + * Determine whether the property is readable. + */ + public function isReadable(): bool + { + return true; + } + + /** + * Determine whether the property is writable. + */ + public function isWritable(): bool + { + return false; + } + + /** + * Determine whether the target property is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->targetProperty?->isDeprecated() ?? TrinaryLogic::createNo(); + } + + /** + * Get the target property's deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->targetProperty?->getDeprecatedDescription(); + } + + /** + * Determine whether the target property is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->targetProperty?->isInternal() ?? TrinaryLogic::createNo(); + } +} diff --git a/src/phpstan/src/Reflection/ReflectionHelper.php b/src/phpstan/src/Reflection/ReflectionHelper.php new file mode 100644 index 0000000000..9cd0d54676 --- /dev/null +++ b/src/phpstan/src/Reflection/ReflectionHelper.php @@ -0,0 +1,118 @@ + */ + private static array $propertyTagCache = []; + + /** @var array */ + private static array $methodTagCache = []; + + /** + * Determine whether the given class or its ancestors declare the property with an `@property*` tag, or a mixin has it. + */ + public static function hasPropertyTag(ClassReflection $classReflection, string $propertyName): bool + { + // Hypervel: keyed by the specialization, because a generic class's mixin can depend on its template types. + $cacheKey = $classReflection->getCacheKey() . '-' . $propertyName; + + if (array_key_exists($cacheKey, self::$propertyTagCache)) { + return self::$propertyTagCache[$cacheKey]; + } + + return self::$propertyTagCache[$cacheKey] = self::resolvePropertyTag($classReflection, $propertyName); + } + + /** + * Resolve whether the class, its ancestors or its mixins declare the property. + */ + private static function resolvePropertyTag(ClassReflection $classReflection, string $propertyName): bool + { + if (array_key_exists($propertyName, $classReflection->getPropertyTags())) { + return true; + } + + foreach ($classReflection->getAncestors() as $ancestor) { + if (array_key_exists($propertyName, $ancestor->getPropertyTags())) { + return true; + } + } + + // @phpstan-ignore phpstanApi.constructor (Reuses PHPStan's mixin lookup, which also checks traits and parents.), phpstanApi.method (Same mixin lookup.) + return (new MixinPropertiesClassReflectionExtension([$classReflection->getName()])) + ->hasProperty($classReflection, $propertyName); + } + + /** + * Determine whether the given class or its ancestors declare the method with an `@method` tag, or a mixin has it. + */ + public static function hasMethodTag(ClassReflection $classReflection, string $methodName): bool + { + // Hypervel: keyed by the specialization, because a generic class's mixin can depend on its template types. + $cacheKey = $classReflection->getCacheKey() . '-' . $methodName; + + if (array_key_exists($cacheKey, self::$methodTagCache)) { + return self::$methodTagCache[$cacheKey]; + } + + return self::$methodTagCache[$cacheKey] = self::resolveMethodTag($classReflection, $methodName); + } + + /** + * Resolve whether the class, its ancestors or its mixins declare the method. + */ + private static function resolveMethodTag(ClassReflection $classReflection, string $methodName): bool + { + if (array_key_exists($methodName, $classReflection->getMethodTags())) { + return true; + } + + foreach ($classReflection->getAncestors() as $ancestor) { + if (array_key_exists($methodName, $ancestor->getMethodTags())) { + return true; + } + } + + // @phpstan-ignore phpstanApi.constructor (Reuses PHPStan's mixin lookup, which also checks traits and parents.), phpstanApi.method (Same mixin lookup.) + return (new MixinMethodsClassReflectionExtension([$classReflection->getName()])) + ->hasMethod($classReflection, $methodName); + } + + /** + * Find a class attribute as the runtime reads it: on each class of the hierarchy, then the + * traits it uses directly, then its parent (`ClassMetadataCache::getAttribute()` and + * `Model::resolveClassAttribute()`). + * + * Return the attribute, the class the runtime treats as declaring it (the using class for a + * trait's attribute), and the class or trait whose source declares it. + * + * @return null|array{AttributeReflection, ClassReflection, ClassReflection} + */ + public static function findClassAttribute(ClassReflection $classReflection, string $attributeClass): ?array + { + for ($class = $classReflection; $class !== null; $class = $class->getParentClass()) { + foreach ([$class, ...array_values($class->getTraits())] as $source) { + foreach ($source->getAttributes() as $attribute) { + if (strcasecmp($attribute->getName(), $attributeClass) === 0) { + return [$attribute, $class, $source]; + } + } + } + } + + return null; + } +} diff --git a/src/phpstan/src/Reflection/SourceFiles.php b/src/phpstan/src/Reflection/SourceFiles.php new file mode 100644 index 0000000000..40335bf2a5 --- /dev/null +++ b/src/phpstan/src/Reflection/SourceFiles.php @@ -0,0 +1,194 @@ + + */ + public static function forClass(ClassReflection $classReflection, ?string $baseClass): array + { + $sources = []; + + foreach ([$classReflection, ...$classReflection->getParents()] as $ancestor) { + if ($ancestor->getName() === $baseClass) { + break; + } + + $sources[] = $ancestor; + + // A class's getTraits(true) also lists every parent's traits, including the base class's, but a + // trait's lists only the traits it uses. + foreach ($ancestor->getTraits() as $trait) { + $sources = [...$sources, $trait, ...array_values($trait->getTraits(true))]; + } + } + + $files = []; + + foreach ($sources as $source) { + if ($source->getFileName() !== null) { + $files[$source->getFileName()] = true; + } + } + + return array_keys($files); + } + + /** + * Get the files an instance's property defaults and method bodies come from: those declaring the class and + * its parents below a base class, with their traits, and those of the constants the defaults read. + * + * @return list + */ + public static function forInstance(ReflectionProvider $reflectionProvider, ClassReflection $classReflection, ?string $baseClass): array + { + $files = array_fill_keys(self::forClass($classReflection, $baseClass), true); + $constants = []; + + foreach ([$classReflection, ...$classReflection->getParents()] as $ancestor) { + if ($ancestor->getName() === $baseClass) { + break; + } + + foreach ($ancestor->getNativeReflection()->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() !== $ancestor->getName()) { + continue; + } + + // An untyped property without a default has a null default but no expression. + $default = $property->getBetterReflection()->getDefaultValueExpression(); + + if ($default !== null) { + self::addExpressionFiles($reflectionProvider, $ancestor, $default, $files, $constants); + } + } + } + + return array_keys($files); + } + + /** + * Get the files a constant read through a class takes its value from: those declaring the class and its + * parents up to the declaring class, as any of them can override it, and those of the constants it reads. + * + * @return list + */ + public static function forConstant(ReflectionProvider $reflectionProvider, ClassReflection $classReflection, string $name): array + { + $files = []; + $constants = []; + + self::addConstantFiles($reflectionProvider, $classReflection, $name, $files, $constants); + + return array_keys($files); + } + + /** + * Get the files of the constants the arguments of a class's or trait's attribute read. + * + * @return list + */ + public static function forAttributeArguments(ReflectionProvider $reflectionProvider, ClassReflection $classReflection, string $attributeClass): array + { + $files = []; + $constants = []; + + foreach ($classReflection->getNativeReflection()->getAttributes($attributeClass) as $attribute) { + foreach ($attribute->getArgumentsExpressions() as $expression) { + self::addExpressionFiles($reflectionProvider, $classReflection, $expression, $files, $constants); + } + } + + return array_keys($files); + } + + /** + * Add the files a constant read through a class takes its value from. + * + * @param array $files + * @param array $constants the constants already visited + */ + private static function addConstantFiles(ReflectionProvider $reflectionProvider, ClassReflection $classReflection, string $name, array &$files, array &$constants): void + { + // A constant can refer to itself in code that fails only once it runs. + if (isset($constants[$classReflection->getName() . '::' . $name])) { + return; + } + + $constants[$classReflection->getName() . '::' . $name] = true; + + $constant = $classReflection->getConstant($name); + $declaringClass = $constant->getDeclaringClass(); + + $files += array_fill_keys(self::forClass($classReflection, $declaringClass->getParentClass()?->getName()), true); + + // An interface declaring the constant is not among the class's parents. + if ($declaringClass->getFileName() !== null) { + $files[$declaringClass->getFileName()] = true; + } + + // A unit enum case has no value expression. + if ($declaringClass->hasEnumCase($name) && ! $declaringClass->isBackedEnum()) { + return; + } + + self::addExpressionFiles($reflectionProvider, $declaringClass, $constant->getValueExpr(), $files, $constants); + } + + /** + * Add the files of the constants an initializer expression in a class reads. + * + * @param array $files + * @param array $constants the constants already visited + */ + private static function addExpressionFiles(ReflectionProvider $reflectionProvider, ClassReflection $scopeClass, Expr $expression, array &$files, array &$constants): void + { + foreach ((new NodeFinder)->findInstanceOf($expression, ClassConstFetch::class) as $fetch) { + if (! $fetch->class instanceof Name || ! $fetch->name instanceof Identifier || $fetch->name->toLowerString() === 'class') { + continue; + } + + $className = match ($fetch->class->toLowerString()) { + 'self' => $scopeClass->getName(), + 'parent' => $scopeClass->getParentClass()?->getName(), + default => $fetch->class->toString(), + }; + + if ($className === null || ! $reflectionProvider->hasClass($className)) { + continue; + } + + $class = $reflectionProvider->getClass($className); + + if ($class->hasConstant($fetch->name->toString())) { + self::addConstantFiles($reflectionProvider, $class, $fetch->name->toString(), $files, $constants); + } + } + } +} diff --git a/src/phpstan/src/Reflection/StaticMethodReflection.php b/src/phpstan/src/Reflection/StaticMethodReflection.php new file mode 100644 index 0000000000..d1d4269014 --- /dev/null +++ b/src/phpstan/src/Reflection/StaticMethodReflection.php @@ -0,0 +1,147 @@ +methodReflection->getDeclaringClass(); + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return true; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return $this->methodReflection->isPrivate(); + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return $this->methodReflection->isPublic(); + } + + /** + * Return the method docblock. + */ + public function getDocComment(): ?string + { + return $this->methodReflection->getDocComment(); + } + + /** + * Return the method name. + */ + public function getName(): string + { + return $this->methodReflection->getName(); + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this->methodReflection->getPrototype(); + } + + /** + * Return the method variants as the forwarding call receives them. + * + * @return list + */ + public function getVariants(): array + { + // Hypervel: facades and macros pass the target its argument values and return the target's own result, + // so by-reference parameters never receive the caller's variable and static returns describe the target. + return array_map( + static fn (ParametersAcceptor $variant): FunctionVariant => ProxiedMethodReflection::proxyVariant( + $variant, + $variant->getReturnType(), + ), + $this->methodReflection->getVariants(), + ); + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->methodReflection->isDeprecated(); + } + + /** + * Return the method's deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->methodReflection->getDeprecatedDescription(); + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return $this->methodReflection->isFinal(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->methodReflection->isInternal(); + } + + /** + * Return the method's throw type. + */ + public function getThrowType(): ?Type + { + return $this->methodReflection->getThrowType(); + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return $this->methodReflection->hasSideEffects(); + } +} diff --git a/src/head/src/PHPStan/WithHeadMethodReflection.php b/src/phpstan/src/Reflection/WithHeadMethodReflection.php similarity index 98% rename from src/head/src/PHPStan/WithHeadMethodReflection.php rename to src/phpstan/src/Reflection/WithHeadMethodReflection.php index 85d3792b44..78cfe02ed9 100644 --- a/src/head/src/PHPStan/WithHeadMethodReflection.php +++ b/src/phpstan/src/Reflection/WithHeadMethodReflection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Head\PHPStan; +namespace Hypervel\PHPStan\Reflection; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; diff --git a/src/phpstan/src/ReturnTypes/AppEnvironmentReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/AppEnvironmentReturnTypeExtension.php new file mode 100644 index 0000000000..aa8718348f --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AppEnvironmentReturnTypeExtension.php @@ -0,0 +1,84 @@ +class; + } + + /** + * Determine whether the method gets or checks the environment. + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'environment'; + } + + /** + * Get the environment name without arguments, or the result of checking it with them. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + return self::getEnvironmentType($methodCall->getArgs(), $scope, $methodReflection->getVariants()[0]->getReturnType()); + } + + /** + * Get the type of an environment() call with the given arguments. + * + * Hypervel: upstream counts the arguments, but an unpacked empty array passes none. A narrower + * declaration than the inferred type is kept. + * + * @param list $args + */ + public static function getEnvironmentType(array $args, Scope $scope, Type $declaredType): ?Type + { + $type = new StringType; + + foreach ($args as $arg) { + $passesValues = $arg->unpack ? $scope->getType($arg->value)->isIterableAtLeastOnce() : TrinaryLogic::createYes(); + + if ($passesValues->yes()) { + $type = new BooleanType; + + break; + } + + if ($passesValues->maybe()) { + // A later argument can still prove that values are passed. + $type = null; + } + } + + return $type !== null && $declaredType->isSuperTypeOf($type)->yes() ? $type : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/AppFacadeEnvironmentReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/AppFacadeEnvironmentReturnTypeExtension.php new file mode 100644 index 0000000000..c9c19a304c --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AppFacadeEnvironmentReturnTypeExtension.php @@ -0,0 +1,48 @@ +getName() === 'environment'; + } + + /** + * Get the environment name without arguments, or the result of checking it with them. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + return AppEnvironmentReturnTypeExtension::getEnvironmentType( + $methodCall->getArgs(), + $scope, + $methodReflection->getVariants()[0]->getReturnType(), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..f15b0beb0a --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AppMakeDynamicReturnTypeExtension.php @@ -0,0 +1,58 @@ +getName(), ['make', 'makeWith', 'makeTransient', 'get', 'factory'], true); + } + + /** + * Get the type of the resolved container entry. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + return $methodReflection->getName() === 'factory' + ? $this->appMakeHelper->resolveFactoryTypeFromCall($methodCall, $scope) + : $this->appMakeHelper->resolveTypeFromCall($methodCall, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/AppMakeHelper.php b/src/phpstan/src/ReturnTypes/AppMakeHelper.php new file mode 100644 index 0000000000..40dc9e7d4e --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AppMakeHelper.php @@ -0,0 +1,117 @@ +getArgs(); + + if (count($args) === 0) { + return null; + } + + // PHPStan reorders named arguments for extensions but leaves unpacked ones + // as-is, and its own evaluation of an unpacked key would apply a facade alias. + if ($args[0]->unpack) { + return new MixedType; + } + + $argType = $scope->getType($args[0]->value); + + $constantStrings = $argType->getConstantStrings(); + + if (count($constantStrings) === 0) { + return null; + } + + $types = []; + + foreach ($constantStrings as $constantString) { + $types[] = $this->resolveAbstractType($constantString->getValue(), $scope); + } + + return TypeCombinator::union(...$types); + } + + /** + * Resolve the type of the container entry for the given abstract. + * + * @param DependencyTracker&Scope $scope + */ + public function resolveAbstractType(string $abstract, Scope $scope): Type + { + $this->runtimeInputs->track($scope); + + $resolved = $this->resolve($abstract); + + if (is_object($resolved)) { + return new ObjectType($resolved::class); + } + + // Not every class-string has a reflection; PHPStan reports a missing class itself. + $class = (new ConstantStringType($abstract))->getClassStringObjectType()->getObjectClassReflections()[0] ?? null; + + // A class that does not resolve to an object keeps its type. Upstream applies this to any class-string, + // but facade aliases make keys such as 'session' class names, so only the class's own name qualifies. + if ($class === null || strcasecmp(ltrim($abstract, '\\'), $class->getName()) !== 0) { + return new MixedType; + } + + return new ObjectType($class->getName()); + } + + /** + * Resolve the type of the closure returned by a container factory() call. + * + * @param DependencyTracker&Scope $scope + */ + public function resolveFactoryTypeFromCall(MethodCall|StaticCall $call, Scope $scope): ?Type + { + $type = $this->resolveTypeFromCall($call, $scope); + + return $type === null ? null : new ClosureType([], $type, false); + } +} diff --git a/src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000000..f8ab404496 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ArrGetDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,93 @@ +getName(), ['get', 'pull'], true); + } + + /** + * Get the type of the value the key can find, or of the default when it may be missing. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $arrayArg = $methodCall->getArg('array', 0); + $keyArg = $methodCall->getArg('key', 1); + + if ($arrayArg === null || $keyArg === null || ! ArrKeyLookup::appliesTo($scope, $methodCall, [$methodReflection->getName(), ...ArrKeyLookup::GET_METHODS])) { + return null; + } + + $lookups = ArrKeyLookup::find($scope, $arrayArg->value, $keyArg->value); + + if ($lookups === null) { + return null; + } + + $types = []; + $mayMiss = false; + + foreach ($lookups as $candidates) { + $exists = TrinaryLogic::createNo(); + + foreach ($candidates as [$found, $access]) { + if (! $found->no()) { + $types[] = $scope->getType($access); + } + + $exists = $exists->or($found); + } + + $mayMiss = $mayMiss || ! $exists->yes(); + } + + if ($mayMiss) { + $defaultArg = $methodCall->getArg('default', 2); + + // Arr::get() returns value($default), which calls a Closure default. + $types[] = $defaultArg === null + ? new NullType + : $scope->getType(new FuncCall(new FullyQualified('value'), [new Arg($defaultArg->value)])); + } + + return TypeCombinator::union(...$types); + } +} diff --git a/src/phpstan/src/ReturnTypes/AuthExtension.php b/src/phpstan/src/ReturnTypes/AuthExtension.php new file mode 100644 index 0000000000..868e70d874 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AuthExtension.php @@ -0,0 +1,75 @@ +getName() === 'user' + && $methodReflection->getDeclaringClass()->getName() === Guard::class; + } + + /** + * Get the user type of every configured guard. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $authModels = $this->authGuardHelper->getUserModels($scope); + + if (count($authModels) === 0) { + return null; + } + + return TypeCombinator::addNull( + TypeCombinator::union(...array_map( + static fn (string $authModel): Type => new ObjectType($authModel), + $authModels, + )), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/AuthGuardHelper.php b/src/phpstan/src/ReturnTypes/AuthGuardHelper.php new file mode 100644 index 0000000000..52006ef491 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AuthGuardHelper.php @@ -0,0 +1,327 @@ + + */ + private const array GUARD_SELECTION_METHODS = ['guard', 'resolve', 'getConfig']; + + /** + * The AuthManager methods that turn a guard's configured provider into its user provider. + * + * @var list + */ + private const array PROVIDER_SELECTION_METHODS = ['createUserProvider', 'getProviderConfiguration', 'createEloquentProvider', 'createDatabaseProvider']; + + /** + * The resolved guard types, keyed by driver. + * + * @var array + */ + private array $driverTypes = []; + + /** + * The bound auth manager and its reflection, or false when it is not an auth manager. + * + * @var null|array{AuthManager, ClassReflection}|false + */ + private false|array|null $manager = null; + + /** + * Create a new helper instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClosureTypeFactory $closureTypeFactory, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get the type of the guard selected by the given name argument. + * + * Returns null when the call's declared return already describes the guard. + * + * @param DependencyTracker&Scope $scope + * @param list $receiverClasses the receiving classes, empty for a call the bound manager receives + */ + public function getGuardType(?Arg $name, Scope $scope, Type $declaredType, bool $acceptsEnums = true, array $receiverClasses = []): ?Type + { + $manager = $this->getManager($scope, $receiverClasses); + + if ($manager === null || $this->overrides($manager[1], self::GUARD_SELECTION_METHODS)) { + return null; + } + + $guardType = $this->getGuardsType($manager, $this->getGuardNames($name, $scope, $acceptsEnums)); + + if ($guardType === null) { + return null; + } + + // A factory or facade declaring a narrower guard keeps its declaration. + return $declaredType->isSuperTypeOf($guardType)->yes() ? $guardType : null; + } + + /** + * Get the type of the current default guard, which shouldUse() can set to any configured guard, for what a class declares. + * + * Returns null when a configured guard's type is unknown. + * + * @param list $receiverClasses the receiving classes, empty for calls the bound manager receives + */ + public function getDefaultGuardType(ClassReflection $classReflection, array $receiverClasses = []): ?Type + { + $manager = $this->getManager($classReflection, $receiverClasses); + + if ($manager === null || $this->overrides($manager[1], self::GUARD_SELECTION_METHODS)) { + return null; + } + + return $this->getGuardsType($manager, $this->getConfiguredGuardNames()); + } + + /** + * Get the user models the given guards return, or every configured guard returns. + * + * Returns an empty list when any of those guards' users cannot be established. + * + * @param ClassReflection|(DependencyTracker&Scope) $dependent + * @param ?list $guardNames + * @param list $receiverClasses the classes receiving the call that selects the guards, empty for the bound manager + * @return list + */ + public function getUserModels(Scope|ClassReflection $dependent, ?array $guardNames = null, array $receiverClasses = []): array + { + $manager = $this->getManager($dependent, $receiverClasses); + + // The configured providers name a guard's users only while the manager selects + // guards and their providers as AuthManager does. + if ($manager === null || $this->overrides($manager[1], [...self::GUARD_SELECTION_METHODS, ...self::PROVIDER_SELECTION_METHODS])) { + return []; + } + + return $this->getAuthModels($manager[0], $this->getContainer()->get('config'), $guardNames); + } + + /** + * Get the bound auth manager and its reflection when calls on the receiving classes reach it, declaring the + * dependent's dependency on the booted guards and the manager's declaration. + * + * The container resolves the factory contract to the bound manager. Another implementation or manager + * subclass selects guards in its own way, with its own registered creators, even when it overrides nothing. + * + * @param ClassReflection|(DependencyTracker&Scope) $dependent + * @param list $receiverClasses + * @return null|array{AuthManager, ClassReflection} + */ + private function getManager(Scope|ClassReflection $dependent, array $receiverClasses): ?array + { + $this->runtimeInputs->track($dependent); + + if ($this->manager === null) { + $manager = $this->resolve('auth'); + $this->manager = $manager instanceof AuthManager ? [$manager, $this->reflectionProvider->getClass($manager::class)] : false; + } + + if ($this->manager === false) { + return null; + } + + $this->runtimeInputs->trackClass($dependent, $this->manager[1]->getName()); + + foreach ($receiverClasses as $receiverClass) { + if (! in_array($receiverClass, [Factory::class, AuthManager::class, $this->manager[1]->getName()], true)) { + return null; + } + } + + return $this->manager; + } + + /** + * Determine whether the manager overrides any of the given AuthManager methods. + * + * @param list $methodNames + */ + private function overrides(ClassReflection $manager, array $methodNames): bool + { + return array_any( + $methodNames, + static fn (string $methodName): bool => $manager->getNativeMethod($methodName)->getDeclaringClass()->getName() !== AuthManager::class, + ); + } + + /** + * Get the union of the given configured guards' types, or null when one of them is unknown. + * + * @param array{AuthManager, ClassReflection} $manager + * @param list $names + */ + private function getGuardsType(array $manager, array $names): ?Type + { + $types = []; + + foreach ($names as $name) { + $type = $this->getConfiguredGuardType($manager, $name); + + if ($type === null) { + return null; + } + + $types[] = $type; + } + + return $types === [] ? null : TypeCombinator::union(...$types); + } + + /** + * Get the names of the guards a name argument can select. + * + * AuthManager::guard() treats a missing, null or empty name as the current default, + * which shouldUse() can set to any configured guard per coroutine. An unknown name + * can also be any configured guard, because resolving any other name throws. + * + * @param DependencyTracker&Scope $scope + * @return list + */ + public function getGuardNames(?Arg $name, Scope $scope, bool $acceptsEnums): array + { + $type = $name === null || $name->unpack ? null : $scope->getType($name->value); + $values = []; + + foreach ($type?->getConstantStrings() ?? [] as $constantString) { + $values[] = $constantString->getValue(); + } + + if ($values === [] && $acceptsEnums) { + foreach ($type?->getEnumCases() ?? [] as $enumCase) { + // enum_value() resolves a backed case to its value and a pure case to its name. + foreach ($enumCase->getBackingValueType()?->getConstantScalarValues() ?? [$enumCase->getEnumCaseName()] as $value) { + $values[] = (string) $value; + } + } + } + + if ($values === [] || in_array('', $values, true)) { + $this->runtimeInputs->track($scope); + + return $this->getConfiguredGuardNames(); + } + + return $values; + } + + /** + * Get the names of the configured guards. + * + * @return list + */ + private function getConfiguredGuardNames(): array + { + $guards = $this->getContainer()->get('config')->get('auth.guards'); + + $names = []; + + foreach (is_array($guards) ? array_keys($guards) : [] as $name) { + $names[] = (string) $name; + } + + return $names; + } + + /** + * Get the type of the configured guard with the given name. + * + * @param array{AuthManager, ClassReflection} $manager + */ + private function getConfiguredGuardType(array $manager, string $name): ?Type + { + $driver = $this->getContainer()->get('config')->get('auth.guards')[$name]['driver'] ?? null; + + if (! is_string($driver)) { + return null; + } + + if (! array_key_exists($driver, $this->driverTypes)) { + $this->driverTypes[$driver] = $this->resolveDriverType($manager, $driver); + } + + return $this->driverTypes[$driver]; + } + + /** + * Resolve the type of the guard the given driver creates. + * + * @param array{AuthManager, ClassReflection} $manager + */ + private function resolveDriverType(array $manager, string $driver): ?Type + { + [$manager, $managerReflection] = $manager; + + /** @var ?Closure $creator */ + $creator = (fn (): ?Closure => $this->customCreators[$driver] ?? null)->call($manager); + + if ($creator !== null) { + // An overriding callCustomCreator() creates the guard instead of the creator. + $type = $this->overrides($managerReflection, ['callCustomCreator']) + ? $managerReflection->getNativeMethod('callCustomCreator')->getOnlyVariant()->getReturnType() + : $this->closureTypeFactory->fromClosureObject($creator)->getReturnType(); + } else { + $method = 'create' . ucfirst($driver) . 'Driver'; + + if (! $managerReflection->hasNativeMethod($method)) { + return null; + } + + $type = $managerReflection->getNativeMethod($method)->getOnlyVariant()->getReturnType(); + } + + // An untyped creator keeps the declared guard type. + return (new ObjectType(Guard::class))->isSuperTypeOf($type)->yes() ? $type : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/AuthManagerExtension.php b/src/phpstan/src/ReturnTypes/AuthManagerExtension.php new file mode 100644 index 0000000000..a03daed524 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/AuthManagerExtension.php @@ -0,0 +1,74 @@ +getName() === 'user' + && $methodReflection->getDeclaringClass()->getName() === Guard::class; + } + + /** + * Get the user type of every configured guard. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $authModels = $this->authGuardHelper->getUserModels($scope, receiverClasses: $scope->getType($methodCall->var)->getObjectClassNames()); + + if (count($authModels) === 0) { + return null; + } + + return TypeCombinator::addNull( + TypeCombinator::union(...array_map( + static fn (string $authModel): Type => new ObjectType($authModel), + $authModels, + )), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/CastDefinitionDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/CastDefinitionDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000000..dfc2f6e07f --- /dev/null +++ b/src/phpstan/src/ReturnTypes/CastDefinitionDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,169 @@ +, list> + */ + private const array HELPERS = [ + AsArrayObject::class => ['nullable'], + AsBinary::class => ['of', 'ulid', 'uuid'], + AsCollection::class => ['nullable', 'of', 'using'], + AsEncryptedCollection::class => ['of', 'using'], + AsEnumArrayObject::class => ['of'], + AsEnumCollection::class => ['of'], + ]; + + /** + * Get the class whose static calls are typed. + */ + public function getClass(): string + { + return Castable::class; + } + + /** + * Determine whether the method is a cast definition helper. + */ + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return in_array($methodReflection->getName(), self::HELPERS[$methodReflection->getDeclaringClass()->getName()] ?? [], true); + } + + /** + * Get the cast string the helper builds. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $class = $methodReflection->getDeclaringClass(); + + // A subclass could change what a helper calls through static::. + if ( + ! $methodCall->class instanceof Name + || $methodCall->class->isSpecialClassName() + || strcasecmp($scope->resolveName($methodCall->class), $class->getName()) !== 0 + ) { + return null; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants()); + $arguments = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall)?->getArgs(); + $parameters = $parametersAcceptor->getParameters(); + + if ($arguments === null || count($arguments) > count($parameters)) { + return null; + } + + $values = []; + + foreach ($parameters as $index => $parameter) { + $argument = $arguments[$index] ?? null; + + if ($argument === null) { + if (! $parameter->isOptional()) { + return null; + } + + break; + } + + $type = $scope->getType($argument->value); + + if ($argument->unpack || ! $parameter->getType()->isSuperTypeOf($type)->yes()) { + return null; + } + + $scalars = $type->getConstantScalarValues(); + + if (count($scalars) === 1) { + $values[] = $scalars[0]; + + continue; + } + + $mapping = $this->getCallableMapping($type); + + if ($mapping === null) { + return null; + } + + $values[] = $mapping; + } + + // The cast string follows the helper's source, which analysis does not otherwise track. + $scope->trackFileDependency((string) $class->getFileName()); + + $helper = [$class->getName(), $methodReflection->getName()]; + + return new ConstantStringType($helper(...$values)); + } + + /** + * Get the [class, method] mapping a collection helper accepts, or null when the type is not one. + * + * The helpers turn a callable mapping into "class@method", but would cast any other array to a string. + * + * @return null|array{string, string} + */ + private function getCallableMapping(Type $type): ?array + { + $arrays = $type->getConstantArrays(); + + if (count($arrays) !== 1 || ! $type->isConstantArray()->yes() || count($arrays[0]->getValueTypes()) !== 2) { + return null; + } + + $class = $arrays[0]->getValueTypes()[0]->getConstantStrings(); + $method = $arrays[0]->getValueTypes()[1]->getConstantStrings(); + + if (! $arrays[0]->isList()->yes() || count($class) !== 1 || count($method) !== 1) { + return null; + } + + $mapping = [$class[0]->getValue(), $method[0]->getValue()]; + + return is_callable($mapping) ? $mapping : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/CollectionFilterRejectDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/CollectionFilterRejectDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..530424f11b --- /dev/null +++ b/src/phpstan/src/ReturnTypes/CollectionFilterRejectDynamicReturnTypeExtension.php @@ -0,0 +1,150 @@ + ['filter'], + 'reject' => ['reject', 'useAsCallable', 'filter'], + 'where' => ['where', 'operatorForWhere', 'useAsCallable', 'filter'], + ]; + + /** + * Get the class the extension applies to. + */ + public function getClass(): string + { + return Enumerable::class; + } + + /** + * Determine whether the method narrows the collection's values. + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return isset(self::METHODS[$methodReflection->getName()]); + } + + /** + * Get the collection type with the values the callback keeps. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + /** @var 'filter'|'reject'|'where' $methodName */ + $methodName = $methodReflection->getName(); + $calledOnType = TypeCombinator::removeNull($scope->getType($methodCall->var)); + $classReflections = $calledOnType->getObjectClassReflections(); + + // The result is rebuilt from the key and value types, so the collection's own templates must be exactly those two. + // A collection that overrides the methods may keep other values. + if (count($classReflections) !== 1 + || count($classReflections[0]->getTemplateTypeMap()->getTypes()) !== 2 + || ! CollectionHelper::usesFrameworkMethods($classReflections[0], self::METHODS[$methodName])) { + return null; + } + + $templateTypeMap = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap(); + $keyType = $templateTypeMap->getType('TKey'); + $valueType = $templateTypeMap->getType('TValue'); + + // Upstream narrows a template's bound instead, which drops the template and the static return type in generic code. + if ($keyType === null || $valueType === null || $keyType->hasTemplateOrLateResolvableType() || $valueType->hasTemplateOrLateResolvableType()) { + return null; + } + + if (count($methodCall->getArgs()) < 1) { + $modifiedType = match ($methodName) { + 'filter' => TypeCombinator::removeFalsey($valueType), + 'reject' => TypeCombinator::removeTruthy($valueType), + 'where' => null, + }; + + return $modifiedType !== null + ? new GenericObjectType($classReflections[0]->getName(), [$keyType, $modifiedType]) + : null; + } + + $callbackArg = $methodCall->getArgs()[0]->value; + $callbackType = $scope->getType($callbackArg); + + // where() and reject() call only callables that are not strings, and compare anything else. + if ($methodName !== 'filter' && ! ($callbackType->isCallable()->yes() && $callbackType->isString()->no())) { + return null; + } + + if ($methodName === 'reject') { + $callbackArg = $this->negate($callbackArg); + + if ($callbackArg === null) { + return null; + } + } + + // The collection's callbacks receive the value and the key, as array_filter() callbacks do with + // ARRAY_FILTER_USE_BOTH, so PHPStan's array_filter() narrowing applies to both. Upstream narrows + // only the value, through a MutatingScope method outside PHPStan's backward compatibility promise. + $filteredType = $scope->getType(new FuncCall(new FullyQualified('array_filter'), [ + new Arg(new TypeExpr(new ArrayType($keyType, $valueType))), + new Arg($callbackArg), + new Arg(new ConstFetch(new FullyQualified('ARRAY_FILTER_USE_BOTH'))), + ])); + + return new GenericObjectType($classReflections[0]->getName(), [ + $filteredType->getIterableKeyType(), + $filteredType->getIterableValueType(), + ]); + } + + /** + * Get a callback that returns the opposite of an inline callback's single returned expression. + */ + private function negate(Expr $callback): ?ArrowFunction + { + if ($callback instanceof ArrowFunction) { + $expr = $callback->expr; + } elseif ($callback instanceof Closure && count($callback->stmts) === 1 && $callback->stmts[0] instanceof Return_ && $callback->stmts[0]->expr !== null) { + $expr = $callback->stmts[0]->expr; + } else { + return null; + } + + return new ArrowFunction([ + 'params' => $callback->params, + 'expr' => new BooleanNot($expr), + ]); + } +} diff --git a/src/phpstan/src/ReturnTypes/CollectionWhereNotNullDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/CollectionWhereNotNullDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..a87ad3f59a --- /dev/null +++ b/src/phpstan/src/ReturnTypes/CollectionWhereNotNullDynamicReturnTypeExtension.php @@ -0,0 +1,101 @@ +getName() === 'whereNotNull'; + } + + /** + * Get the collection type without the values whereNotNull() removes. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $calledOnType = TypeCombinator::removeNull($scope->getType($methodCall->var)); + $classReflections = $calledOnType->getObjectClassReflections(); + + // The result is rebuilt from the key and value types, so the collection's own templates must be exactly those two. + // A collection that overrides whereNotNull() or the methods it runs may keep other values. + if (count($classReflections) !== 1 + || count($classReflections[0]->getTemplateTypeMap()->getTypes()) !== 2 + || ! CollectionHelper::usesFrameworkMethods($classReflections[0], ['whereNotNull', 'where', 'operatorForWhere', 'useAsCallable', 'filter'])) { + return null; + } + + $templateTypeMap = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap(); + $keyType = $templateTypeMap->getType('TKey'); + $valueType = $templateTypeMap->getType('TValue'); + + // Upstream narrows a template's bound instead, which drops the template and the static return type in generic code. + if ($keyType === null || $valueType === null || $keyType->hasTemplateOrLateResolvableType() || $valueType->hasTemplateOrLateResolvableType()) { + return null; + } + + $nonNullType = TypeCombinator::removeNull($valueType); + + if (! $this->argumentIsString($methodCall, $scope)) { + return new GenericObjectType($classReflections[0]->getName(), [$keyType, $nonNullType]); + } + + // A keyed lookup on a scalar value finds nothing, so scalar values are removed. + $scalarTypes = TypeCombinator::union( + new StringType, + new IntegerType, + new FloatType, + new BooleanType, + ); + + return new GenericObjectType($classReflections[0]->getName(), [$keyType, TypeCombinator::remove($nonNullType, $scalarTypes)]); + } + + /** + * Determine whether the call passes a key to compare. + */ + public function argumentIsString(MethodCall $methodCall, Scope $scope): bool + { + if (count($methodCall->getArgs()) === 0) { + return false; + } + + $argType = $scope->getType($methodCall->getArgs()[0]->value); + + return $argType->isNull()->no(); + } +} diff --git a/src/phpstan/src/ReturnTypes/ConcurrencyDriverHelper.php b/src/phpstan/src/ReturnTypes/ConcurrencyDriverHelper.php new file mode 100644 index 0000000000..766b742747 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConcurrencyDriverHelper.php @@ -0,0 +1,135 @@ + CoroutineDriver::class, + 'process' => ProcessDriver::class, + 'sync' => SyncDriver::class, + ]; + + /** + * Create a new helper instance. + */ + public function __construct( + private ConfigParser $configParser, + ) { + } + + /** + * Get the type of the driver a driver() call selects, or null when any instance it can name is unknown. + * + * @param DependencyTracker&Scope $scope + */ + public function getDriverType(?Arg $name, Scope $scope): ?Type + { + $names = InstanceNames::fromArgument($name, $scope); + + if ($names === null) { + return null; + } + + $types = []; + + foreach ($names as $instance) { + $class = $this->getDriverClass($instance, $scope); + + if ($class === null) { + return null; + } + + $types[] = new ObjectType($class); + } + + return TypeCombinator::union(...$types); + } + + /** + * Get the class of the driver the named instance, or the default instance for null or an empty name, creates when it is known. + * + * @param DependencyTracker&Scope $scope + * + * @return null|class-string + */ + public function getDriverClass(?string $name, Scope $scope): ?string + { + if ($name === null || $name === '') { + $name = $this->getConfigString('concurrency.default', $scope); + } + + if ($name === null) { + return null; + } + + $instance = $this->configParser->getValue("concurrency.driver.{$name}", $scope); + + if ($instance === null || $instance[0]->maybe()) { + return null; + } + + $driver = $instance[0]->yes() + ? $this->getConfigString("concurrency.driver.{$name}.driver", $scope) + : $name; + + if ($driver === null || ! isset(self::DRIVERS[$driver])) { + return null; + } + + $manager = $this->resolve(ConcurrencyManager::class); + + // A subclass may resolve or create its drivers differently. + return $manager instanceof ConcurrencyManager + && $manager::class === ConcurrencyManager::class + && ! (fn (): bool => isset($this->customCreators[$driver]))->call($manager) + ? self::DRIVERS[$driver] + : null; + } + + /** + * Get a config value known to be present and a single constant string, or null. + * + * @param DependencyTracker&Scope $scope + */ + private function getConfigString(string $key, Scope $scope): ?string + { + $value = $this->configParser->getValue($key, $scope); + $strings = $value !== null && $value[0]->yes() ? $value[1]->getConstantStrings() : []; + + return count($strings) === 1 ? $strings[0]->getValue() : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/ConcurrencyDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConcurrencyDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000000..e81b608cc6 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConcurrencyDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,54 @@ +getName() === 'driver'; + } + + /** + * Get the type of the selected driver. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + return $this->driverHelper->getDriverType($methodCall->getArgs()[0] ?? null, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/ConcurrencyManagerDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConcurrencyManagerDynamicMethodReturnTypeExtension.php new file mode 100644 index 0000000000..42ece76b15 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConcurrencyManagerDynamicMethodReturnTypeExtension.php @@ -0,0 +1,61 @@ +getName() === 'driver'; + } + + /** + * Get the type of the selected driver. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + // A subclass may resolve or create its drivers differently. + foreach ($scope->getType($methodCall->var)->getObjectClassNames() as $receiverClass) { + if ($receiverClass !== ConcurrencyManager::class) { + return null; + } + } + + return $this->driverHelper->getDriverType($methodCall->getArgs()[0] ?? null, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php b/src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php new file mode 100644 index 0000000000..b452e079a6 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConditionableExpressionTypeResolverExtension.php @@ -0,0 +1,204 @@ +name instanceof Identifier + || ! in_array($expr->name->toLowerString(), ['when', 'unless'], true) + || $expr->isFirstClassCallable()) { + return null; + } + + $argumentCount = $this->countArguments($expr->getArgs()); + + if ($argumentCount === null) { + return null; + } + + $calledOnType = match (true) { + $expr instanceof MethodCall => $scope->getType($expr->var), + $expr->class instanceof Name => $scope->resolveTypeByName($expr->class), + default => $scope->getType($expr->class)->getObjectTypeOrClassStringObjectType(), + }; + $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); + + if ($methodReflection === null) { + return null; + } + + $prototype = $methodReflection->getPrototype(); + $forwarded = $prototype instanceof ForwardedModelMethodReflection + || ($prototype instanceof ForwardedMethodReflection + && $expr instanceof MethodCall + && (new ObjectType(Relation::class))->isSuperTypeOf($calledOnType)->yes()) + ? $prototype + : null; + $modelCall = $forwarded instanceof ForwardedModelMethodReflection; + + // Only Model::__callStatic() reaches Conditionable statically; a parent:: call keeps its declaration. + if ($expr instanceof StaticCall && ! $modelCall) { + return null; + } + + if (! $this->isConditionable($forwarded?->getForwardedMethod() ?? $methodReflection)) { + return null; + } + + $variant = ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $methodReflection->getVariants()); + $runningType = $forwarded === null ? $calledOnType : $this->getCallbackSubjectType($variant, $scope); + + // Conditionable passes $this, so the proxy's target is the object running it. + if ($argumentCount < 2) { + return new GenericObjectType(HigherOrderWhenProxy::class, [$runningType, new ConstantBooleanType($argumentCount === 1)]); + } + + // Conditionable declares these templates, so isConditionable() guarantees them. + /** @var Type $callbackReturnType */ + $callbackReturnType = $variant->getResolvedTemplateTypeMap()->getType( + $expr->name->toLowerString() === 'when' ? 'TWhenReturnType' : 'TUnlessReturnType', + ); + $instanceType = $modelCall ? $runningType : $calledOnType; + + // The template holds what the callback or default returns other than null, and the call + // returns the instance otherwise. PHPStan leaves the template an error type when no + // callback or default returns a value, where its declared default is never. + if ($callbackReturnType instanceof ErrorType) { + return $instanceType; + } + + if ($forwarded === null || $modelCall) { + return TypeCombinator::union($instanceType, $callbackReturnType); + } + + // Relation::__call() returns the relation in place of its own builder. Only the builder the callback + // was given keeps the "this" type RelationConditionableCallbackExtension gives it; a clone is static. + return TypeCombinator::union($instanceType, ...array_map( + static fn (Type $type): Type => match (true) { + ! $type instanceof StaticType || ! $runningType->isSuperTypeOf($type)->yes() => $type, + $type instanceof ThisType => $calledOnType, + default => $type->getStaticObjectType(), + }, + TypeUtils::flattenTypes($callbackReturnType), + )); + } + + /** + * Get the type of the object Conditionable passes to the callback as $this: the builder of a forwarded call. + */ + private function getCallbackSubjectType(ParametersAcceptor $variant, Scope $scope): Type + { + return TypeCombinator::removeNull($variant->getParameters()[1]->getType()) + ->getCallableParametersAcceptors($scope)[0] + ->getParameters()[0] + ->getType(); + } + + /** + * Count the arguments as func_num_args() does: up to the last parameter given. + * + * @param list $args + */ + private function countArguments(array $args): ?int + { + $count = 0; + + foreach ($args as $position => $arg) { + if ($arg->unpack) { + return null; + } + + $index = $arg->name === null ? $position : array_search($arg->name->toString(), self::PARAMETERS, true); + + if ($index === false) { + return null; + } + + $count = max($count, $index + 1); + } + + return $count; + } + + /** + * Determine whether the method is Conditionable's own implementation. + */ + private function isConditionable(MethodReflection $methodReflection): bool + { + $declaringClass = $methodReflection->getDeclaringClass(); + + // A trait method keeps the trait's file. + return $declaringClass->hasNativeMethod($methodReflection->getName()) + && $declaringClass->getNativeReflection()->getMethod($methodReflection->getName())->getFileName() + === $this->reflectionProvider->getClass(Conditionable::class)->getFileName(); + } +} diff --git a/src/phpstan/src/ReturnTypes/ConfigFacadeDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConfigFacadeDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000000..372871e6ef --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConfigFacadeDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,60 @@ +configHelper->isConfigMethod($methodReflection); + } + + /** + * Get the type of the configuration value the method returns. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + return $this->configHelper->getMethodReturnType($methodCall, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/ConfigRepositoryDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConfigRepositoryDynamicMethodReturnTypeExtension.php new file mode 100644 index 0000000000..6f96d2a8ed --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConfigRepositoryDynamicMethodReturnTypeExtension.php @@ -0,0 +1,56 @@ +configHelper->isConfigMethod($methodReflection); + } + + /** + * Get the type of the configuration value the method returns. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + return $this->configHelper->getMethodReturnType($methodCall, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..ed8384143d --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/ArgumentDynamicReturnTypeExtension.php @@ -0,0 +1,109 @@ +getName(), ['argument', 'arguments'], true) + && $methodReflection->getDeclaringClass()->getName() === Command::class; + } + + /** + * Get the type of the requested command arguments. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + // Upstream reads the enclosing class, which mistypes calls on another command. + $commands = $this->consoleApplicationResolver->findCommandsForType($scope->getType($methodCall->var), $scope); + + if ($commands === []) { + return null; + } + + $args = $methodCall->getArgs(); + + if ($args === [] + || $methodReflection->getName() === 'arguments' + || (! $args[0]->unpack && $scope->getType($args[0]->value)->isNull()->yes()) + ) { + return $this->consoleApplicationHelper->getArguments($commands, $scope); + } + + $argStrings = $scope->getType($args[0]->value)->getConstantStrings(); + + if (count($argStrings) === 0) { + return null; + } + + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants())->getReturnType(); + + $returnTypes = []; + + foreach ($argStrings as $argString) { + $argName = $argString->getValue(); + + $argTypes = []; + + foreach ($commands as $command) { + try { + $argument = $command->getDefinition()->getArgument($argName); + + $argTypes[] = $this->consoleApplicationHelper->getArgumentType($scope, $argument); + } catch (InvalidArgumentException) { + // noop + } + } + + // Upstream added the partial union for each command, so the result depended on their order. + $returnTypes[] = count($argTypes) > 0 ? TypeCombinator::union(...$argTypes) : $defaultReturnType; + } + + return TypeCombinator::union(...$returnTypes); + } +} diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..2a9b5aec3d --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasArgumentDynamicReturnTypeExtension.php @@ -0,0 +1,84 @@ +getName() === 'hasArgument' + && $methodReflection->getDeclaringClass()->getName() === Command::class; + } + + /** + * Get whether the command defines the given argument. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + if ($methodCall->getArgs() === []) { + return null; + } + + $constantStrings = $scope->getType($methodCall->getArgs()[0]->value)->getConstantStrings(); + + if (count($constantStrings) !== 1) { + return null; + } + + $returnTypes = []; + + // Upstream reads the enclosing class, which mistypes calls on another command. + foreach ($this->consoleApplicationResolver->findCommandsForType($scope->getType($methodCall->var), $scope) as $command) { + $returnTypes[] = $command->getDefinition()->hasArgument($constantStrings[0]->getValue()); + } + + if (count($returnTypes) === 0) { + return null; + } + + $returnTypes = array_unique($returnTypes); + + return count($returnTypes) === 1 ? new ConstantBooleanType($returnTypes[0]) : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..46281d9a6d --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/HasOptionDynamicReturnTypeExtension.php @@ -0,0 +1,87 @@ +getName() === 'hasOption' + && $methodReflection->getDeclaringClass()->getName() === Command::class; + } + + /** + * Get whether the command defines the given option or negation. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + if ($methodCall->getArgs() === []) { + return null; + } + + $constantStrings = $scope->getType($methodCall->getArgs()[0]->value)->getConstantStrings(); + + if (count($constantStrings) !== 1) { + return null; + } + + $argName = $constantStrings[0]->getValue(); + + $returnTypes = []; + + // Upstream reads the enclosing class, which mistypes calls on another command. + foreach ($this->consoleApplicationResolver->findCommandsForType($scope->getType($methodCall->var), $scope) as $command) { + // Input::hasOption() accepts negations such as no-ansi, but not the shortcuts that upstream accepted. + $returnTypes[] = $command->getDefinition()->hasOption($argName) || $command->getDefinition()->hasNegation($argName); + } + + if (count($returnTypes) === 0) { + return null; + } + + $returnTypes = array_unique($returnTypes); + + return count($returnTypes) === 1 ? new ConstantBooleanType($returnTypes[0]) : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..bef14fd054 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ConsoleCommand/OptionDynamicReturnTypeExtension.php @@ -0,0 +1,113 @@ +getName(), ['option', 'options'], true) + && $methodReflection->getDeclaringClass()->getName() === Command::class; + } + + /** + * Get the type of the requested command options. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + // Upstream reads the enclosing class, which mistypes calls on another command. + $commands = $this->consoleApplicationResolver->findCommandsForType($scope->getType($methodCall->var), $scope); + + if ($commands === []) { + return null; + } + + $args = $methodCall->getArgs(); + + if ($args === [] + || $methodReflection->getName() === 'options' + || (! $args[0]->unpack && $scope->getType($args[0]->value)->isNull()->yes()) + ) { + return $this->consoleApplicationHelper->getOptions($commands, $scope); + } + + $argStrings = $scope->getType($args[0]->value)->getConstantStrings(); + + if (count($argStrings) === 0) { + return null; + } + + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants())->getReturnType(); + + $returnTypes = []; + + foreach ($argStrings as $argString) { + $argName = $argString->getValue(); + + $argTypes = []; + + foreach ($commands as $command) { + try { + $definition = $command->getDefinition(); + + // Input::getOption() reads negations such as no-ansi, but not the shortcuts that + // upstream accepted. A negatable option takes no value, so the type stays bool|null. + $argument = $definition->getOption($definition->hasNegation($argName) ? $definition->negationToName($argName) : $argName); + + $argTypes[] = $this->consoleApplicationHelper->getOptionType($scope, $argument); + } catch (InvalidArgumentException) { + // noop + } + } + + // Upstream added the partial union for each command, so the result depended on their order. + $returnTypes[] = count($argTypes) > 0 ? TypeCombinator::union(...$argTypes) : $defaultReturnType; + } + + return TypeCombinator::union(...$returnTypes); + } +} diff --git a/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..41080bc7b1 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ContainerMakeDynamicReturnTypeExtension.php @@ -0,0 +1,63 @@ +getName(), ['make', 'makeWith', 'makeTransient', 'get', 'resolve', 'factory'], true); + } + + /** + * Get the type of the resolved container entry. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + return $methodReflection->getName() === 'factory' + ? $this->appMakeHelper->resolveFactoryTypeFromCall($methodCall, $scope) + : $this->appMakeHelper->resolveTypeFromCall($methodCall, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/CursorPaginatorConstructorReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/CursorPaginatorConstructorReturnTypeExtension.php new file mode 100644 index 0000000000..bd94ba95da --- /dev/null +++ b/src/phpstan/src/ReturnTypes/CursorPaginatorConstructorReturnTypeExtension.php @@ -0,0 +1,72 @@ +getName() === '__construct'; + } + + /** + * Get the paginator type with integer keys and the value type the arguments infer. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + // A subclass declares its own templates. + if (! $methodCall->class instanceof Name || strtolower($scope->resolveName($methodCall->class)) !== strtolower(CursorPaginator::class)) { + return null; + } + + $variant = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants()); + $valueType = $variant->getResolvedTemplateTypeMap()->getType('TValue'); + + if ($valueType === null || $valueType instanceof ErrorType) { + return null; + } + + // The stored collection keeps its default, as for any hand-built paginator. + $defaults = $methodReflection->getDeclaringClass()->getTemplateTypeMap()->resolveToBounds()->getTypes(); + + return new GenericObjectType(CursorPaginator::class, [new IntegerType, $valueType, $defaults['TCollection']]); + } +} diff --git a/src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php new file mode 100644 index 0000000000..963cc0fc2c --- /dev/null +++ b/src/phpstan/src/ReturnTypes/DoubleUnderscoreHelperReturnTypeExtension.php @@ -0,0 +1,58 @@ +getName() === '__'; + } + + /** + * Get the translation type for a key that cannot be null or comes from unpacked lists. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + // Upstream returns null only for a call without arguments. An explicit or + // possibly null key keeps the helper's declared conditional return type. + $key = $functionCall->getArgs()[0] ?? null; + + if ($key !== null && $key->unpack) { + return $this->getUnpackedHelperType($functionReflection, $functionCall, $scope); + } + + if ($key === null || ! $scope->getType($key->value)->isNull()->no()) { + return null; + } + + return new BenevolentUnionType([ + new ArrayType(new MixedType, new MixedType), + new StringType, + ]); + } +} diff --git a/src/phpstan/src/ReturnTypes/EloquentBuilderExtension.php b/src/phpstan/src/ReturnTypes/EloquentBuilderExtension.php new file mode 100644 index 0000000000..86adce8335 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/EloquentBuilderExtension.php @@ -0,0 +1,79 @@ +getName(), 'find') || ! CollectionHelper::isFrameworkQueryMethod($methodReflection)) { + return false; + } + + $modelType = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap()->getType('TModel'); + + return $modelType !== null && $modelType->getObjectClassNames() !== []; + } + + /** + * Return the model's collection class in place of the declared Eloquent collection. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $returnType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + + if (! in_array(Collection::class, $returnType->getReferencedClasses(), true)) { + return null; + } + + // Unlike upstream, only the collections inside the return type are replaced, so a paginator + // of models stays a paginator. + return $this->collectionHelper->replaceCollectionsInType($returnType); + } +} diff --git a/src/phpstan/src/ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..5855a8474c --- /dev/null +++ b/src/phpstan/src/ReturnTypes/EloquentCollectionMapDynamicReturnTypeExtension.php @@ -0,0 +1,85 @@ +getName(), ['map', 'mapWithKeys'], true) + && $methodReflection->getDeclaringClass()->getName() === EloquentCollection::class; + } + + /** + * Get the base collection type when the callback might return a value that is not a model. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + if ($methodCall->getArgs() === []) { + return null; + } + + // The receiver can be a union with other types that have these methods. + $calledOnType = TypeCombinator::intersect( + $scope->getType($methodCall->var), + new ObjectType(EloquentCollection::class), + ); + + // Same as the runtime: map the items as a base collection, then decide which class is kept. + $baseCollectionType = new GenericObjectType(Collection::class, [ + $calledOnType->getTemplateType(Collection::class, 'TKey'), + $calledOnType->getTemplateType(Collection::class, 'TValue'), + ]); + + $baseMethodReflection = $baseCollectionType->getMethod($methodReflection->getName(), $scope); + + $mappedType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $baseMethodReflection->getVariants(), + $baseMethodReflection->getNamedArgumentsVariants(), + )->getReturnType(); + + $mappedValueType = $mappedType->getTemplateType(Collection::class, 'TValue'); + + if (! (new ObjectType(Model::class))->isSuperTypeOf($mappedValueType)->maybe()) { + return null; + } + + return $mappedType; + } +} diff --git a/src/phpstan/src/ReturnTypes/EloquentCollectionToQueryDynamicReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/EloquentCollectionToQueryDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..ae679d8b5d --- /dev/null +++ b/src/phpstan/src/ReturnTypes/EloquentCollectionToQueryDynamicReturnTypeExtension.php @@ -0,0 +1,69 @@ +, which is newQuery()'s builder. The collection cannot be + * stubbed, and an analyzer-only return type in source would read as mixed without this package, so the + * source keeps Builder. + */ +#[AutowiredService] +class EloquentCollectionToQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension +{ + /** + * Create the toQuery() return type extension. + */ + public function __construct( + private BuilderHelper $builderHelper, + ) { + } + + /** + * Get the class the extension applies to. + */ + public function getClass(): string + { + return Collection::class; + } + + /** + * Determine whether the method is the Eloquent collection's own toQuery(). + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + // A collection that overrides the method has its own contract. + return $methodReflection->getName() === 'toQuery' + && $methodReflection->getDeclaringClass()->getName() === Collection::class; + } + + /** + * Get the builder of the collection's model. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): Type { + $modelType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants()) + ->getReturnType() + ->getTemplateType(Builder::class, 'TModel'); + + return (new BuilderOfType($modelType, $this->builderHelper, modelQuery: true))->resolve(); + } +} diff --git a/src/phpstan/src/ReturnTypes/FactoryDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/FactoryDynamicMethodReturnTypeExtension.php new file mode 100644 index 0000000000..4199b426d7 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/FactoryDynamicMethodReturnTypeExtension.php @@ -0,0 +1,122 @@ +getType($methodCall->var); + + if (! $calledOnType instanceof ModelFactoryType) { + return null; + } + + $methodName = $methodReflection->getName(); + + // Unlike upstream, a factory's own implementation of these methods is not treated as the base one. + $isBaseMethod = $methodReflection->getDeclaringClass()->getName() === Factory::class; + + if ($isBaseMethod && in_array($methodName, ['count', 'times'], true)) { + // Unlike upstream's error type, a missing count keeps the declaration beside PHPStan's own report. + if ($methodCall->getArgs() === []) { + return null; + } + + $argType = $scope->getType($methodCall->getArgs()[0]->value); + + if ($argType->isInteger()->yes()) { + return $calledOnType->withSingleModel(TrinaryLogic::createNo()); + } + + if ($argType->isNull()->yes()) { + return $calledOnType->withSingleModel(TrinaryLogic::createYes()); + } + + return $calledOnType->withSingleModel(TrinaryLogic::createMaybe()); + } + + // Unlike upstream, which keeps the receiver's count, new() starts a factory without a count, and + // forEachSequence() sets one. + if ($isBaseMethod && $methodName === 'new') { + return $calledOnType->withSingleModel(TrinaryLogic::createYes()); + } + + if ($isBaseMethod && $methodName === 'forEachSequence') { + return $calledOnType->withSingleModel(TrinaryLogic::createNo()); + } + + $originalReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + + if (in_array($methodName, ['create', 'createQuietly', 'make'], true)) { + if (! $isBaseMethod) { + return null; + } + + if ($calledOnType->isSingleModel()->yes()) { + return TypeCombinator::remove($originalReturnType, new ObjectType(Collection::class)); + } + + if ($calledOnType->isSingleModel()->no()) { + return TypeCombinator::remove($originalReturnType, new ObjectType(Model::class)); + } + } + + // Unlike upstream, only a method returning the factory's own class keeps its count: a broader return, + // such as mixed or another factory, is not this factory. + if ($originalReturnType->getObjectClassNames() !== [$calledOnType->getClassName()] + || ! $originalReturnType->isSuperTypeOf($calledOnType)->yes()) { + return null; + } + + // A factory's own count(), times(), new() or forEachSequence() may set the count differently. Returning + // null would not help: PHPStan resolves a declared static return to the receiver, with its old count. + if (in_array($methodName, ['count', 'times', 'new', 'forEachSequence'], true)) { + return $calledOnType->withSingleModel(TrinaryLogic::createMaybe()); + } + + return $calledOnType; + } +} diff --git a/src/phpstan/src/ReturnTypes/FactoryDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/FactoryDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000000..ac38b98d4a --- /dev/null +++ b/src/phpstan/src/ReturnTypes/FactoryDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,80 @@ +getDeclaringClass()->getName() === Factory::class + && in_array($methodReflection->getName(), ['new', 'times'], true); + } + + /** + * Get the factory, which new() creates without a count and times() with one. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + // self::, static:: and parent:: forward the late static class, so their declared static stays. + if (! $methodCall->class instanceof Name || $methodCall->class->isSpecialClassName()) { + return null; + } + + // A missing count keeps the declaration beside PHPStan's own report. + if ($methodReflection->getName() === 'times' && $methodCall->getArgs() === []) { + return null; + } + + $factoryReflections = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants()) + ->getReturnType() + ->getObjectClassReflections(); + + // A factory that declares its own templates keeps its generic declaration, as in Model::factory(). + if (count($factoryReflections) !== 1 || $factoryReflections[0]->isGeneric()) { + return null; + } + + return new ModelFactoryType( + $factoryReflections[0]->getName(), + null, + $factoryReflections[0], + TrinaryLogic::createFromBoolean($methodReflection->getName() === 'new'), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php b/src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php new file mode 100644 index 0000000000..e5bbb2dc89 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/FilesystemDiskHelper.php @@ -0,0 +1,276 @@ + + */ + private array $diskTypes = []; + + /** + * The bound manager and its reflection, or false when neither follows the stock resolution. + * + * @var null|array{FilesystemManager, ClassReflection}|false + */ + private false|array|null $manager = null; + + /** + * The class of the bound manager, when it is a filesystem manager. + * + * @var null|class-string + */ + private ?string $managerClass = null; + + /** + * Create a new helper instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ClosureTypeFactory $closureTypeFactory, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get the type of the disk a disk() or drive() call selects. + * + * Returns null when the call's declared return already describes the disk. + * + * @param DependencyTracker&Scope $scope + * @param list $receiverClasses the receiving manager classes, empty for the Storage facade + */ + public function getDiskType(string $methodName, ?Arg $name, Scope $scope, Type $declaredType, array $receiverClasses = []): ?Type + { + $manager = $this->getManager($scope); + + if ($manager === null) { + return null; + } + + [, $managerReflection] = $manager; + + foreach ($receiverClasses as $receiverClass) { + // A subclass other than the bound one resolves its disks in its own way. + if ($receiverClass !== FilesystemManager::class && $receiverClass !== $managerReflection->getName()) { + return null; + } + } + + // drive() delegates to disk(), so an override of either replaces the stock selection. + if ($this->overrides($managerReflection, 'disk') || $this->overrides($managerReflection, $methodName)) { + return null; + } + + $names = InstanceNames::fromArgument($name, $scope); + + if ($names === null) { + return null; + } + + $types = []; + + foreach ($names as $diskName) { + $type = $this->getNamedDiskType($manager, $diskName); + + if ($type === null) { + return null; + } + + $types[] = $type; + } + + $diskType = TypeCombinator::union(...$types); + + // A facade or manager declaring a narrower disk keeps its declaration. + return $declaredType->isSuperTypeOf($diskType)->yes() ? $diskType : null; + } + + /** + * Get the type of the default disk, which the manager forwards unknown calls to, for what a class declares. + */ + public function getDefaultDiskType(ClassReflection $classReflection): ?Type + { + $manager = $this->getManager($classReflection); + + // __call() forwards to disk(), so an override of either replaces the stock forwarding. + if ($manager === null || $this->overrides($manager[1], '__call') || $this->overrides($manager[1], 'disk')) { + return null; + } + + return $this->getNamedDiskType($manager, null); + } + + /** + * Get the type of the disk with the given name, resolving it once per name. + * + * @param array{FilesystemManager, ClassReflection} $manager + */ + private function getNamedDiskType(array $manager, ?string $name): ?Type + { + if ($name === null || $name === '') { + try { + $name = $manager[0]->getDefaultDriver(); + } catch (InvalidArgumentException) { + // There is no configured default disk. + return null; + } + } + + if (! array_key_exists($name, $this->diskTypes)) { + $this->diskTypes[$name] = $this->resolveDiskType($manager, $name); + } + + return $this->diskTypes[$name]; + } + + /** + * Resolve the declared type of the named disk, following FilesystemManager::resolveConstructionDescriptor(). + * + * @param array{FilesystemManager, ClassReflection} $manager + */ + private function resolveDiskType(array $manager, string $name): ?Type + { + [$manager, $managerReflection] = $manager; + + try { + // getConfig() and prepareConstructionDescriptor() are internal to FilesystemManager. The latter + // collapses scoped disks into the disk they scope, so their stock construction is that disk's. + $facts = Closure::bind(function (string $name): array { + $config = $this->getConfig($name); + $driver = $this->prepareConstructionDescriptor($config, $name)['config']['driver'] ?? null; + + return [ + 'scoped' => ($config['driver'] ?? null) === 'scoped', + 'driver' => $driver, + 'creator' => is_string($driver) ? ($this->customCreators[$driver] ?? null) : null, + 'poolable' => in_array($driver, $this->poolableDrivers, true), + 'cached' => $this->disks[$name] ?? null, + ]; + }, $manager, FilesystemManager::class)($name); + } catch (InvalidArgumentException) { + // The disk is not configured, or its configuration is invalid. + return null; + } + + $driver = $facts['driver']; + + if (! is_string($driver) || $driver === '' || ($facts['scoped'] && $this->overrides($managerReflection, 'createScopedDriver'))) { + return null; + } + + if ($facts['creator'] instanceof Closure) { + $type = match (true) { + $facts['poolable'] => $this->getReturnType($managerReflection, 'createDriverPooledDisk'), + $this->overrides($managerReflection, 'callCustomCreator') => $this->getReturnType($managerReflection, 'callCustomCreator'), + default => $this->closureTypeFactory->fromClosureObject($facts['creator'])->getReturnType(), + }; + } elseif ($facts['poolable'] && in_array($driver, ['s3', 'gcs'], true)) { + $type = $this->getReturnType($managerReflection, 'createClientPooledDisk'); + } else { + $method = 'create' . Str::studly($driver) . 'Driver'; + + if (! $managerReflection->hasNativeMethod($method)) { + // The manager does not support the driver. + return null; + } + + $type = $this->getReturnType($managerReflection, $facts['poolable'] ? 'createDriverPooledDisk' : $method); + } + + // disk() keeps returning a disk it already resolved or was given through set() or a fake. + // Its class is exact, so a parent of the computed type does not fit either. + if ($facts['cached'] !== null && ! $type->isSuperTypeOf(ConstantTypeHelper::getTypeFromValue($facts['cached']))->yes()) { + return null; + } + + return $type; + } + + /** + * Get the bound filesystem manager and its reflection when it resolves disks the stock way. + * + * @param ClassReflection|(DependencyTracker&Scope) $dependent + * @return null|array{FilesystemManager, ClassReflection} + */ + private function getManager(Scope|ClassReflection $dependent): ?array + { + $this->runtimeInputs->track($dependent); + + if ($this->manager === null) { + $manager = $this->resolve('filesystem'); + $this->manager = false; + + if ($manager instanceof FilesystemManager) { + $this->managerClass = $manager::class; + $reflection = $this->reflectionProvider->getClass($manager::class); + + // These replace how a selected disk name becomes a disk. + if (! $this->overrides($reflection, 'get') && ! $this->overrides($reflection, 'resolve') && ! $this->overrides($reflection, 'getConfig')) { + $this->manager = [$manager, $reflection]; + } + } + } + + // The bound manager's own methods, such as getDefaultDriver(), select the disk. + if ($this->managerClass !== null) { + $this->runtimeInputs->trackSources($dependent, $this->managerClass, FilesystemManager::class); + } + + return $this->manager === false ? null : $this->manager; + } + + /** + * Determine whether the manager overrides the given FilesystemManager method. + */ + private function overrides(ClassReflection $classReflection, string $methodName): bool + { + return $classReflection->getNativeMethod($methodName)->getDeclaringClass()->getName() !== FilesystemManager::class; + } + + /** + * Get the declared return type of the manager's method. + */ + private function getReturnType(ClassReflection $classReflection, string $methodName): Type + { + return $classReflection->getNativeMethod($methodName)->getOnlyVariant()->getReturnType(); + } +} diff --git a/src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php new file mode 100644 index 0000000000..e156e44078 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/FilesystemManagerDynamicMethodReturnTypeExtension.php @@ -0,0 +1,66 @@ +getName(), ['disk', 'drive'], true); + } + + /** + * Get the type of the selected disk. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + return $this->diskHelper->getDiskType( + $methodReflection->getName(), + $methodCall->getArgs()[0] ?? null, + $scope, + $methodReflection->getVariants()[0]->getReturnType(), + $scope->getType($methodCall->var)->getObjectClassNames(), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/FormRequestSafeDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/FormRequestSafeDynamicMethodReturnTypeExtension.php new file mode 100644 index 0000000000..d77d4d0c80 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/FormRequestSafeDynamicMethodReturnTypeExtension.php @@ -0,0 +1,98 @@ +class; + } + + /** + * Determine whether the method returns validated input. + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'safe'; + } + + /** + * Get the shape of the validated input for the given keys. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $args = $methodCall->getArgs(); + + if (count($args) === 0 || $args[0]->unpack) { + return null; + } + + $argType = $scope->getType($args[0]->value); + + if (! $argType->isConstantArray()->yes()) { + return null; + } + + $types = []; + + foreach ($argType->getConstantArrays() as $constantArray) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($constantArray->getValueTypes() as $keyType) { + $keys = $keyType->getConstantStrings(); + + // Hypervel: a non-constant key can name any input, so the shape is unknown. + if ($keys === []) { + return null; + } + + foreach ($keys as $constantString) { + // Hypervel: only() nests dotted keys and expands wildcards. + if (str_contains($constantString->getValue(), '.') || str_contains($constantString->getValue(), '*')) { + return null; + } + + // Hypervel: optional, because only() omits a key the validated input does not contain. + $builder->setOffsetValueType($constantString, new MixedType, true); + } + } + + $types[] = $builder->getArray(); + } + + return TypeCombinator::union(...$types); + } +} diff --git a/src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php new file mode 100644 index 0000000000..a4209c878e --- /dev/null +++ b/src/phpstan/src/ReturnTypes/GuardDynamicMethodReturnTypeExtension.php @@ -0,0 +1,65 @@ +guard('web') kept the declared Guard. + */ +#[AutowiredService] +class GuardDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension +{ + /** + * Create a new extension instance. + */ + public function __construct( + private AuthGuardHelper $authGuardHelper, + ) { + } + + /** + * Get the class whose method calls are typed. + */ + public function getClass(): string + { + return Factory::class; + } + + /** + * Determine whether the method is the factory's guard(). + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'guard'; + } + + /** + * Get the type of the requested guard. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + return $this->authGuardHelper->getGuardType( + $methodCall->getArgs()[0] ?? null, + $scope, + ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(), + receiverClasses: $scope->getType($methodCall->var)->getObjectClassNames(), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000000..b00851ad9c --- /dev/null +++ b/src/phpstan/src/ReturnTypes/GuardDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,62 @@ +getName() === 'guard'; + } + + /** + * Get the type of the requested guard. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + // Upstream falls back to StatefulGuard, which only the session guard is, and types a missing + // name as the configured default, which shouldUse() changes per coroutine. + return $this->authGuardHelper->getGuardType( + $methodCall->getArgs()[0] ?? null, + $scope, + ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/GuardExtension.php b/src/phpstan/src/ReturnTypes/GuardExtension.php new file mode 100644 index 0000000000..f421fa93cc --- /dev/null +++ b/src/phpstan/src/ReturnTypes/GuardExtension.php @@ -0,0 +1,150 @@ +getName() === 'user'; + } + + /** + * Get the user type of the guard the call was made on. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $declaredType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + + // Unlike upstream, a guard declaring a narrower user() than the contract keeps its declaration. + if (! $declaredType->isSuperTypeOf(TypeCombinator::addNull(new ObjectType(Authenticatable::class)))->yes()) { + return null; + } + + [$guardNames, $receiverClasses] = $this->getGuardSelection($scope, $methodCall); + $authModels = $this->authGuardHelper->getUserModels($scope, $guardNames, $receiverClasses); + + if (count($authModels) === 0) { + return null; + } + + return TypeCombinator::addNull( + TypeCombinator::union(...array_map( + static fn (string $authModel): Type => new ObjectType($authModel), + $authModels, + )), + ); + } + + /** + * Get the names of the guards the call that returned the guard can select, and the classes receiving that call. + * + * The names are null for a guard that did not come from the auth factory, the Auth + * facade or the auth() helper, which may be any configured guard. + * + * @param DependencyTracker&Scope $scope + * @return array{?list, list} + */ + private function getGuardSelection(Scope $scope, MethodCall $methodCall): array + { + $call = $methodCall->var; + + // Upstream reads the argument of any single-argument call, such as $this->guardFor('admins'). + if ($call instanceof MethodCall + && $call->name instanceof Identifier + && $call->name->toLowerString() === 'guard' + && (new ObjectType(Factory::class))->isSuperTypeOf($scope->getType($call->var))->yes() + ) { + return [ + $this->authGuardHelper->getGuardNames($this->getArgument($call, 'name'), $scope, acceptsEnums: true), + $scope->getType($call->var)->getObjectClassNames(), + ]; + } + + if ($call instanceof StaticCall + && $call->class instanceof Name + && $call->name instanceof Identifier + && $call->name->toLowerString() === 'guard' + && (new ObjectType(Auth::class))->isSuperTypeOf(new ObjectType($scope->resolveName($call->class)))->yes() + ) { + return [$this->authGuardHelper->getGuardNames($this->getArgument($call, 'name'), $scope, acceptsEnums: true), []]; + } + + if ($call instanceof FuncCall + && $call->name instanceof Name + && strtolower((string) $this->reflectionProvider->resolveFunctionName($call->name, $scope)) === 'auth' + ) { + return [$this->authGuardHelper->getGuardNames($this->getArgument($call, 'guard'), $scope, acceptsEnums: false), []]; + } + + return [null, []]; + } + + /** + * Get the argument a call passes for its first parameter. + */ + private function getArgument(CallLike $call, string $parameter): ?Arg + { + foreach ($call->getArgs() as $position => $arg) { + if ($arg->name === null ? $position === 0 : $arg->name->toString() === $parameter) { + return $arg; + } + } + + return null; + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/AppExtension.php b/src/phpstan/src/ReturnTypes/Helpers/AppExtension.php new file mode 100644 index 0000000000..c703044cc2 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/AppExtension.php @@ -0,0 +1,49 @@ +getName() === 'app' || $functionReflection->getName() === 'resolve'; + } + + /** + * Get the type of the resolved container entry. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + // A missing or null abstract keeps the helper's declared Application return, + // so upstream's separate no-argument branch is unnecessary. + return $this->appMakeHelper->resolveTypeFromCall($functionCall, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php b/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php new file mode 100644 index 0000000000..219b91e73c --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/AuthExtension.php @@ -0,0 +1,95 @@ +getName() === 'auth'; + } + + /** + * Get the type of the auth factory or requested guard. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + $guard = $functionCall->getArgs()[0] ?? null; + + // Unlike guard(null), auth() and auth(null) return the factory rather than the default guard. + if ($guard === null || (! $guard->unpack && $scope->getType($guard->value)->isNull()->yes())) { + return $this->getFactoryType($scope); + } + + if ($guard->unpack) { + return $this->getUnpackedHelperType($functionReflection, $functionCall, $scope, $this->getFactoryType($scope)); + } + + // A maybe-null guard can still return the factory, so the declared conditional applies. + if (! $scope->getType($guard->value)->isNull()->no()) { + return null; + } + + // Upstream types every named guard as StatefulGuard, which only the session guard is. + return $this->authGuardHelper->getGuardType( + $guard, + $scope, + ParametersAcceptorSelector::selectFromArgs($scope, $functionCall->getArgs(), $functionReflection->getVariants())->getReturnType(), + acceptsEnums: false, + ); + } + + /** + * Get the type of the booted auth factory. + * + * @param DependencyTracker&Scope $scope + */ + private function getFactoryType(Scope $scope): ?Type + { + $this->runtimeInputs->track($scope); + + $factory = $this->resolve(Factory::class); + + return is_object($factory) ? new ObjectType($factory::class) : null; + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php b/src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php new file mode 100644 index 0000000000..da446bbdb2 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/CollectExtension.php @@ -0,0 +1,81 @@ +getName() === 'collect'; + } + + /** + * Get the collection type for the collected value. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + if (count($functionCall->getArgs()) < 1) { + if ($this->inferFromUsage) { + return null; + } + + return $this->emptyCollectionType ??= new GenericObjectType(Collection::class, [new BenevolentUnionType([new IntegerType, new StringType]), new MixedType]); + } + + $argument = $functionCall->getArgs()[0]; + + // An unpacked argument's expression is the list holding the value, not the value. + if ($argument->unpack) { + return null; + } + + $valueType = $scope->getType($argument->value); + + if ($this->inferFromUsage && $valueType->isArray()->yes() && $valueType->isIterableAtLeastOnce()->no()) { + return null; + } + + return $this->collectionHelper->determineGenericCollectionTypeFromType($valueType); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ConfigFunctionDynamicFunctionReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ConfigFunctionDynamicFunctionReturnTypeExtension.php new file mode 100644 index 0000000000..f3f92442eb --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/ConfigFunctionDynamicFunctionReturnTypeExtension.php @@ -0,0 +1,47 @@ +getName() === 'config'; + } + + /** + * Get the type of the configuration value the helper returns for a key. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + return $this->configHelper->getValueType($functionCall, $scope); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/LiteralExtension.php b/src/phpstan/src/ReturnTypes/Helpers/LiteralExtension.php new file mode 100644 index 0000000000..5764cf37ce --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/LiteralExtension.php @@ -0,0 +1,184 @@ +getName() === 'literal'; + } + + /** + * Get the type of the single positional argument, or of the object built from the arguments. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + $args = $functionCall->getArgs(); + + // Handle the case of a single argument, returning its type directly + if (count($args) === 1) { + $argType = $scope->getType($args[0]->value); + + $nameOfParam = $args[0]->getAttributes()['originalArg']->name->name ?? null; + + if ($nameOfParam === null) { + // Upstream also returns a non-constant unpacked array itself, which literal() + // never returns, an object for a one-item list, which it returns unwrapped, + // and keeps integer keys that PHP passes by position. + if ($args[0]->unpack) { + $constantArrays = $argType->getConstantArrays(); + + return $constantArrays === [] + ? new MixedType + : TypeCombinator::union(...array_map($this->getTypeFromConstantArray(...), $constantArrays)); + } + + if ($argType->isObject()->yes() && in_array(stdClass::class, $argType->getReferencedClasses(), true)) { + return TypeCombinator::intersect(new ObjectShapeType([], []), $argType); + } + + return $argType; + } + } + + $properties = []; + + foreach ($args as $index => $argExpression) { + // The names an unpacked argument passes are not known here. + if ($argExpression->unpack) { + return new MixedType; + } + + $nameOfParam = $argExpression->getAttributes()['originalArg']->name->name ?? null; + + if ($nameOfParam === null) { + // Handle unnamed arguments + $nameOfParam = $index; + } + + $properties[(string) $nameOfParam] = $scope->getType($argExpression->value); + } + + return TypeCombinator::intersect( + new ObjectShapeType($properties, []), + new ObjectType(stdClass::class), + ); + } + + /** + * Get the type literal() returns for the given unpacked arguments. + */ + protected function getTypeFromConstantArray(ConstantArrayType $argType): Type + { + // An unsealed array may pass any number of other arguments. + if ($argType->isUnsealed()->yes()) { + return new MixedType; + } + + $valueTypes = $argType->getValueTypes(); + $positionalTypes = []; + $requiredPositionalTypes = []; + $positionalProperties = []; + $namedProperties = []; + $optionalProperties = []; + $optionalPosition = null; + $passesName = false; + + foreach ($argType->getKeyTypes() as $i => $keyType) { + $key = $keyType->getValue(); + $isOptional = $argType->isOptionalKey($i); + + if (is_string($key)) { + $namedProperties[$key] = $valueTypes[$i]; + + if ($isOptional) { + $optionalProperties[] = $key; + } else { + $passesName = true; + } + + continue; + } + + // PHP passes integer keys by position, and throws when one follows a named argument, + // so an optional one is never passed then. + if ($passesName) { + if ($isOptional) { + continue; + } + + return new NeverType(true); + } + + // An optional argument moves the ones after it, so only earlier positions are known. + if ($optionalPosition === null) { + $position = count($positionalTypes); + $positionalProperties[$position] = $valueTypes[$i]; + + if ($isOptional) { + $optionalPosition = $position; + } + } + + $positionalTypes[] = $valueTypes[$i]; + + if (! $isOptional) { + $requiredPositionalTypes[] = $valueTypes[$i]; + } + } + + // The optional argument's own position is known only when no positional argument follows it. + if ($optionalPosition !== null) { + if ($optionalPosition === count($positionalTypes) - 1) { + $optionalProperties[] = $optionalPosition; + } else { + unset($positionalProperties[$optionalPosition]); + } + } + + $types = []; + + // A single positional argument is returned as it is. + if (! $passesName && $positionalTypes !== [] && count($requiredPositionalTypes) <= 1) { + $types[] = $requiredPositionalTypes[0] ?? TypeCombinator::union(...$positionalTypes); + } + + if (count($positionalTypes) !== 1 || $requiredPositionalTypes === [] || $namedProperties !== []) { + $types[] = TypeCombinator::intersect( + new ObjectShapeType($positionalProperties + $namedProperties, $optionalProperties), + new ObjectType(stdClass::class), + ); + } + + return TypeCombinator::union(...$types); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php b/src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php new file mode 100644 index 0000000000..00ed1eef55 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/LoggerExtension.php @@ -0,0 +1,62 @@ +getName() === 'logger'; + } + + /** + * Get the logger for a null message, and otherwise null. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + return $this->getBoundHelperType( + $functionReflection, + $functionCall, + $scope, + LoggerInterface::class, + fn (): Type => $this->appMakeHelper->resolveAbstractType(LoggerInterface::class, $scope), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php new file mode 100644 index 0000000000..5fe5a479b3 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/ResponseExtension.php @@ -0,0 +1,62 @@ +getName() === 'response'; + } + + /** + * Get the factory for a call without arguments, and otherwise the response. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + // Upstream counts arguments, so it types every call with unpacked arguments as a response, though they may be empty. + // Hypervel: the factory is the booted binding, as for app(ResponseFactory::class), rather than the contract. + return $this->getFactoryHelperType( + $functionCall, + fn (): Type => $this->appMakeHelper->resolveAbstractType(ResponseFactory::class, $scope), + $this->responseType ??= new ObjectType(Response::class), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/StrExtension.php b/src/phpstan/src/ReturnTypes/Helpers/StrExtension.php new file mode 100644 index 0000000000..b5e8f45053 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/StrExtension.php @@ -0,0 +1,51 @@ +getName() === 'str'; + } + + /** + * Get mixed for a call without arguments, and otherwise a stringable. + * + * Without arguments, str() returns an anonymous class that passes method calls to Str. This + * extension does not model that proxy, so it keeps upstream's mixed type instead of the + * declared object type, which would reject every call. + */ + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + // Upstream counts arguments, so it types every call with unpacked arguments as a stringable, though they may be empty. + return $this->getFactoryHelperType( + $functionCall, + fn (): Type => $this->mixedType ??= new MixedType, + $this->stringableType ??= new ObjectType(Stringable::class), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php b/src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php new file mode 100644 index 0000000000..b8b9dd2dce --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/UrlExtension.php @@ -0,0 +1,63 @@ +getName() === 'url'; + } + + /** + * Get the generator for a null path, and otherwise the URL. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + return $this->getBoundHelperType( + $functionReflection, + $functionCall, + $scope, + UrlGenerator::class, + fn (): Type => $this->appMakeHelper->resolveAbstractType(UrlGenerator::class, $scope), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php new file mode 100644 index 0000000000..0c8aab5e2b --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/ValidatorExtension.php @@ -0,0 +1,64 @@ +getName() === 'validator'; + } + + /** + * Get the factory for a call without arguments, and otherwise the validator. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + // Upstream counts arguments, so it types every call with unpacked arguments as a validator, though they may be empty. + // It also claims the concrete validator, but the helper makes one through the bound factory + // contract, whose make() promises only the validator contract. + // Hypervel: the factory is the booted binding, as for app(Factory::class), rather than the contract. + return $this->getFactoryHelperType( + $functionCall, + fn (): Type => $this->appMakeHelper->resolveAbstractType(Factory::class, $scope), + $this->validatorType ??= new ObjectType(Validator::class), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php new file mode 100644 index 0000000000..f1b3d93296 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/ValueExtension.php @@ -0,0 +1,72 @@ +getName() === 'value'; + } + + /** + * Get the value's type, or the return type of a Closure value. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + if (count($functionCall->getArgs()) === 0) { + return new NeverType; + } + + $argument = $functionCall->getArgs()[0]; + + // Upstream types the list an unpacked argument holds as the value itself. + if ($argument->unpack) { + return null; + } + + $argType = $scope->getType($argument->value); + + return TypeTraverser::map($argType, static function (Type $type, callable $traverse) use ($scope): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + // Like value(), only call a Closure and return any other value as it is. + $isClosure = (new ObjectType(Closure::class))->isSuperTypeOf($type); + + if ($isClosure->no() || ! $type->isCallable()->yes()) { + return $type; + } + + $returnType = $type->getCallableParametersAcceptors($scope)[0]->getReturnType(); + + return $isClosure->yes() ? $returnType : TypeCombinator::union($type, $returnType); + }); + } +} diff --git a/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php b/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php new file mode 100644 index 0000000000..7cbe614b74 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/Helpers/ViewExtension.php @@ -0,0 +1,66 @@ +getName() === 'view'; + } + + /** + * Get the factory for a call without arguments, and otherwise the view. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type { + // The factory is the booted binding, as for app(Factory::class), rather than the contract. + return $this->getFactoryHelperType( + $functionCall, + fn (): Type => $this->appMakeHelper->resolveAbstractType(Factory::class, $scope), + $this->viewType ??= new ObjectType(View::class), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php b/src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php new file mode 100644 index 0000000000..cdc8c195a6 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/HigherOrderTapProxyExtension.php @@ -0,0 +1,48 @@ +getType($methodCall->var)->getTemplateType(HigherOrderTapProxy::class, 'TTarget'); + + return $targetType->getObjectClassNames() === [] ? new MixedType : $targetType; + } +} diff --git a/src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php new file mode 100644 index 0000000000..7a6ae062a4 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/LangGetReturnTypeExtension.php @@ -0,0 +1,67 @@ +getName() === 'get'; + } + + /** + * Get the translation type. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $translation = new UnionType([ + new ArrayType(new MixedType, new MixedType), + new StringType, + ]); + + $declaredType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $methodReflection->getVariants(), + )->getReturnType(); + + // Upstream always returns the union, which widens a facade whose get() declares a narrower type. + if (! $declaredType->isSuperTypeOf($translation)->yes()) { + return null; + } + + return new BenevolentUnionType($translation->getTypes()); + } +} diff --git a/src/phpstan/src/ReturnTypes/ModelDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ModelDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000000..4641cbb08c --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ModelDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,189 @@ +isForwardedToBuilder($methodReflection) || $this->isNativeModelMethod($methodReflection->getName()); + } + + /** + * Return the called model's builder or collection type. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $forwardedType = $this->isForwardedToBuilder($methodReflection) + ? $this->getTypeFromForwardedBuilderCall($methodCall, $scope) + : null; + + if ($forwardedType !== null) { + return $forwardedType; + } + + // Unlike upstream, a model's own declaration, such as HasBuilder's query(), is kept, and so is an + // instance method's: parent::newQuery() runs on $this, so its late static model applies. + if (! $this->isNativeModelMethod($methodReflection->getName()) + || ! $methodReflection->isStatic() + || $methodReflection->getDeclaringClass()->getName() !== Model::class) { + return null; + } + + // Unlike upstream, the declaration decides: a callback's result, such as withoutEvents()', is not the called + // model's builder or collection. + $returnType = $methodReflection->getDeclaringClass() + ->getMethod($methodReflection->getName(), $scope) + ->getVariants()[0] + ->getReturnType(); + + // Unlike upstream, which typed self::query() as the enclosing model, self::, parent:: and $this:: keep the called + // model, as PHP forwards it and PHPStan's own static calls do; the call returns another instance, so not $this. + // A class name in the hierarchy names that class. + $modelType = match (true) { + ! $methodCall->class instanceof Name => $scope->getType($methodCall->class)->getObjectTypeOrClassStringObjectType(), + $scope->isInClass() && in_array($methodCall->class->toLowerString(), ['self', 'static', 'parent'], true) => $scope->resolveTypeByName(new Name('static')), + default => $scope->resolveTypeByName($methodCall->class), + }; + + if ($modelType instanceof ThisType) { + $modelType = $methodCall->class instanceof Name + ? $modelType->getStaticObjectType() + : new StaticType($modelType->getClassReflection()); + } + + // A final class has no subclass to call it, so its static is the class itself, as PHPStan resolves it. + if ($modelType instanceof StaticType && $modelType->getClassReflection()->isFinal()) { + $modelType = $modelType->getStaticObjectType(); + } + + if ((new ObjectType(EloquentBuilder::class))->isSuperTypeOf($returnType)->yes()) { + if (! (new ObjectType(Model::class))->isSuperTypeOf($modelType)->yes()) { + return null; + } + + return new BuilderOfType($modelType, $this->builderHelper); + } + + if (in_array(Collection::class, $returnType->getReferencedClasses(), true)) { + $types = []; + + foreach ($modelType->getObjectClassNames() as $modelName) { + $types[] = $this->collectionHelper->determineCollectionClass($modelName, TypeCombinator::intersect($modelType, new ObjectType($modelName))); + } + + if ($types !== []) { + return TypeCombinator::union(...$types); + } + } + + return null; + } + + /** + * Determine whether Model::__callStatic() forwards the call to the Eloquent builder. + */ + private function isForwardedToBuilder(MethodReflection $methodReflection): bool + { + return $methodReflection->getPrototype() instanceof ForwardedModelMethodReflection; + } + + /** + * Determine whether the base model declares the method. + */ + private function isNativeModelMethod(string $name): bool + { + return $this->reflectionProvider->getClass(Model::class)->hasNativeMethod($name); + } + + /** + * Resolve a forwarded call on a builder of the called model type, so `$class::findOrFail()` + * on a `class-string` keeps T. + */ + private function getTypeFromForwardedBuilderCall(StaticCall $methodCall, Scope $scope): ?Type + { + if ($methodCall->class instanceof Name) { + return null; + } + + $modelType = $scope->getType($methodCall->class)->getObjectTypeOrClassStringObjectType(); + + // A concrete model class already resolves through ForwardedModelMethodExtension, + // a template type is replaced by its bound there. + if (! TypeUtils::containsTemplateType($modelType)) { + return null; + } + + $modelClassNames = $modelType->getObjectClassNames(); + + if (count($modelClassNames) !== 1) { + return null; + } + + $builderType = $this->builderHelper->determineBuilderClass($modelClassNames[0], $modelType); + + if ($builderType === null) { + return null; + } + + return $scope->getType(new MethodCall( + new TypeExpr($builderType), + $methodCall->name, + $methodCall->getArgs(), + )); + } +} diff --git a/src/phpstan/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000000..d9e49551b6 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,287 @@ +getName() === 'factory'; + } + + /** + * Get the model's factory, recording whether it makes one model or several. + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $class = $methodCall->class; + + // Unlike upstream, which gives never, a class-string receiver names its model. + $calledOnType = $class instanceof Name + ? new ObjectType($scope->resolveName($class)) + : $scope->getType($class)->getObjectTypeOrClassStringObjectType(); + + if ($calledOnType->getObjectClassReflections() === []) { + return null; + } + + // PHPStan reports a receiver union in which a model has no factory(). + foreach ($calledOnType->getObjectClassReflections() as $classReflection) { + if (! $classReflection->hasMethod('factory')) { + return null; + } + } + + if (count($methodCall->getArgs()) === 0) { + $isSingleModel = TrinaryLogic::createYes(); + } elseif ($methodCall->getArgs()[0]->unpack) { + // PHPStan does not expand an unpacked argument for extensions. + $isSingleModel = TrinaryLogic::createMaybe(); + } else { + $argType = $scope->getType($methodCall->getArgs()[0]->value); + + $numericType = TypeCombinator::union( + new IntegerType, + new FloatType, + TypeCombinator::intersect( + new StringType, + new AccessoryNumericStringType, + ), + ); + + $isSingleModel = $numericType->isSuperTypeOf($argType)->negate()->result; + } + + return TypeCombinator::union(...array_map( + function (ClassReflection $classReflection) use ($scope, $isSingleModel): Type { + // Unlike upstream's error type, a model whose factory is not known, or whose own factory() + // implementation decides it, keeps the declared return. + $factoryReflection = $this->usesHasFactoryMethod($classReflection, 'factory') + ? $this->getFactoryReflection($classReflection, $scope) + : null; + + if ($factoryReflection === null) { + return $classReflection->getMethod('factory', $scope)->getVariants()[0]->getReturnType(); + } + + // A factory that declares its own templates keeps its generic type, whose create() and make() + // declare a model or a collection, rather than a count that its template arguments would lose. + if ($factoryReflection->isGeneric()) { + return $factoryReflection->getObjectType(); + } + + return new ModelFactoryType($factoryReflection->getName(), null, $factoryReflection, $isSingleModel); + }, + $calledOnType->getObjectClassReflections(), + )); + } + + /** + * Get the factory the model's factory() call returns. + */ + private function getFactoryReflection( + ClassReflection $modelReflection, + Scope $scope, + ): ?ClassReflection { + // An override of newFactory() is the most specific declaration. When the model does not + // override it, factory() returns the HasFactory TFactory generic. + $factoryReflection = $this->getFactoryFromMethodReturnType($modelReflection, 'newFactory', $scope); + + if ($factoryReflection !== null) { + return $factoryReflection; + } + + // Hypervel: HasFactory's own newFactory() selects a static $factory default, then the model's own + // #[UseFactory] attribute, before the naming convention. One it cannot resolve leaves the TFactory + // declaration rather than a lower-priority choice. + if ($this->usesHasFactoryMethod($modelReflection, 'newFactory')) { + $selection = $this->getSelectedFactory($modelReflection); + + if ($selection !== false) { + return $selection ?? $this->getFactoryFromMethodReturnType($modelReflection, 'factory', $scope); + } + } + + // TFactory is bound on the class that uses HasFactory. A subclass that inherits the trait + // gets the factory of its own conventional name. + if (! $this->isFactoryGenericBoundTo($modelReflection)) { + return $this->getFactoryFromNamingConvention($modelReflection) + ?? $this->getFactoryFromMethodReturnType($modelReflection, 'factory', $scope); + } + + return $this->getFactoryFromMethodReturnType($modelReflection, 'factory', $scope) + ?? $this->getFactoryFromNamingConvention($modelReflection); + } + + /** + * Determine whether the HasFactory TFactory generic applies to the model itself. + */ + private function isFactoryGenericBoundTo(ClassReflection $modelReflection): bool + { + // Callers handle a factory() override and a static $factory default before this. + return $modelReflection->getNativeReflection()->getMethod('factory')->getDeclaringClass()->getName() === $modelReflection->getName(); + } + + /** + * Determine whether the model's method is HasFactory's own rather than an override. + */ + private function usesHasFactoryMethod(ClassReflection $modelReflection, string $methodName): bool + { + return $modelReflection->hasNativeMethod($methodName) + && $modelReflection->getNativeReflection()->getMethod($methodName)->getFileName() === $this->reflectionProvider->getClass(HasFactory::class)->getFileName(); + } + + /** + * Get the factory a static $factory default or the model's own #[UseFactory] attribute selects. + * + * Returns false when neither selects one, and null when the selection cannot be resolved. + */ + private function getSelectedFactory(ClassReflection $modelReflection): ClassReflection|false|null + { + if ($modelReflection->hasNativeProperty('factory') && $modelReflection->getNativeProperty('factory')->isStatic()) { + // A property default is not part of the model's declarations. + foreach (SourceFiles::forInstance($this->reflectionProvider, $modelReflection, Model::class) as $file) { + $this->declarationDependencyTracker->trackFileDependency($modelReflection, $file); + } + + $factoryClass = $modelReflection->getNativeReflection()->getDefaultProperties()['factory'] ?? null; + + // newFactory() only reads the property when it is set. + if ($factoryClass !== null) { + return is_string($factoryClass) ? $this->findFactory($factoryClass) : null; + } + } + + // PHP attributes are not inherited, and newFactory() reads only the model's own. + $attributes = $modelReflection->getNativeReflection()->getAttributes(UseFactory::class); + + if ($attributes === []) { + return false; + } + + $arguments = $attributes[0]->getArgumentsExpressions(); + $factoryClass = $this->attributeClassNames->resolve($arguments[0] ?? $arguments['factoryClass'] ?? null, $modelReflection, $modelReflection); + + return $factoryClass !== null ? $this->findFactory($factoryClass) : null; + } + + /** + * Get the factory named by the factory name resolver. + */ + private function getFactoryFromNamingConvention(ClassReflection $modelReflection): ?ClassReflection + { + return $this->findFactory(Factory::resolveFactoryName($modelReflection->getName())); + } + + /** + * Get the concrete factory class of the given name. + */ + private function findFactory(string $className): ?ClassReflection + { + // Unlike upstream's class_exists(), this does not load the analyzed factory file. + if (! $this->reflectionProvider->hasClass($className)) { + return null; + } + + $factoryReflection = $this->reflectionProvider->getClass($className); + + if (! $factoryReflection->is(Factory::class) || $factoryReflection->isAbstract()) { + return null; + } + + // A class named without template arguments takes their defaults or bounds, as PHPStan resolves + // a bare generic class name. + return $factoryReflection->isGeneric() ? $factoryReflection->withTypes([]) : $factoryReflection; + } + + /** + * Get the concrete factory a model method declares it returns. + */ + private function getFactoryFromMethodReturnType( + ClassReflection $modelReflection, + string $methodName, + Scope $scope, + ): ?ClassReflection { + if (! $modelReflection->hasMethod($methodName)) { + return null; + } + + // Read through the model's type, so a declared static, as in GenericFactory, is the called model. + $factoryReflections = $modelReflection->getObjectType() + ->getMethod($methodName, $scope) + ->getVariants()[0] + ->getReturnType() + ->getObjectClassReflections(); + + if (count($factoryReflections) !== 1) { + return null; + } + + foreach ($factoryReflections as $factoryReflection) { + if ( + $factoryReflection->is(Factory::class) + && ! $factoryReflection->isAbstract() + ) { + return $factoryReflection; + } + } + + return null; + } +} diff --git a/src/phpstan/src/ReturnTypes/ModelOnlyDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ModelOnlyDynamicMethodReturnTypeExtension.php new file mode 100644 index 0000000000..ab9c975ba7 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ModelOnlyDynamicMethodReturnTypeExtension.php @@ -0,0 +1,198 @@ +getName() === 'only' + && $methodReflection->getDeclaringClass()->getName() === Model::class; + } + + /** + * Return the attribute shapes the given keys select. + * + * Each alternative of the first argument selects its own keys, so `only($flag ? 'name' : 'email')` + * returns `array{name: T}|array{email: T}` rather than one shape with both keys. Within one shape, + * a later value with several possible keys makes each of them optional, which keeps the work + * linear in the number of keys. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $slots = []; + + foreach ($methodCall->getArgs() as $argument) { + $type = $scope->getType($argument->value); + + if (! $argument->unpack) { + $slots[] = [$type, true]; + + continue; + } + + // String keys unpack as named arguments. + if (! $type->getIterableKeyType()->isInteger()->yes()) { + return null; + } + + $alternatives = $this->valueSlots($type); + + // Several alternatives merge into one slot that holds any number of their values. + $slots = [ + ...$slots, + ...(count($alternatives) === 1 ? $alternatives[0] : [[$type->getIterableValueType(), false]]), + ]; + } + + // An optional first value hides which argument only() reads first. + if ($slots === [] || ! $slots[0][1]) { + return null; + } + + $shapes = []; + + foreach (TypeUtils::flattenTypes($slots[0][0]) as $firstType) { + // only() reads an array first argument alone, and otherwise every argument as a key. + if ($firstType->isArray()->yes()) { + foreach ($this->valueSlots($firstType) as $valueSlots) { + $shapes[] = $this->keyShape($valueSlots); + } + } else { + $shapes[] = $this->keyShape([[$firstType, true], ...array_slice($slots, 1)]); + } + } + + if (in_array(null, $shapes, true)) { + return null; + } + + $model = $scope->getType($methodCall->var); + $results = []; + + foreach ($shapes as $keys) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + // A numeric key name becomes an integer offset, as it does in the result. + foreach ($keys as $name => $required) { + $builder->setOffsetValueType(ConstantTypeHelper::getTypeFromValue($name), $this->attributeType($model, (string) $name, $scope), ! $required); + } + + $results[] = $builder->getArray(); + } + + return TypeCombinator::union(...$results); + } + + /** + * Return the keys that key slots select, each mapped to whether it is always present. + * + * Null means a key is not a known string. + * + * @param list $slots each slot's key type and whether the slot is always present + * @return null|array + */ + private function keyShape(array $slots): ?array + { + $shape = []; + + foreach ($slots as [$type, $required]) { + $keys = $type->getConstantStrings(); + + if ($keys === []) { + return null; + } + + // A slot with several possible keys selects only one of them. + $present = $required && count($keys) === 1; + + foreach ($keys as $key) { + $name = $key->getValue(); + $shape[$name] = $present || ($shape[$name] ?? false); + } + } + + return $shape; + } + + /** + * Return the value slots of each alternative of an array, each with whether it is always present. + * + * @return list> + */ + private function valueSlots(Type $type): array + { + $alternatives = []; + + foreach ($type->getArrays() as $array) { + $constantArrays = $array->getConstantArrays(); + + // An array of unknown size may hold any number of its values. + if ($constantArrays === []) { + $alternatives[] = [[$array->getIterableValueType(), false]]; + + continue; + } + + foreach ($constantArrays as $constantArray) { + $slots = []; + + foreach ($constantArray->getValueTypes() as $offset => $valueType) { + $slots[] = [$valueType, ! $constantArray->isOptionalKey($offset)]; + } + + $alternatives[] = $slots; + } + } + + return $alternatives; + } + + /** + * Return the type getAttribute() reads for the key. + * + * Unlike upstream, an unknown key reads as mixed rather than null, as the property does: the + * value can be a selected alias that no column or accessor declares. + */ + private function attributeType(Type $model, string $name, Scope $scope): Type + { + return $model->hasInstanceProperty($name)->yes() + ? $model->getInstanceProperty($name, $scope)->getReadableType() + : new MixedType; + } +} diff --git a/src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php new file mode 100644 index 0000000000..4013592029 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/ModelSerializationDynamicMethodReturnTypeExtension.php @@ -0,0 +1,277 @@ +getName(), ['toArray', 'attributesToArray'], true); + } + + /** + * Get the array type a model serializes to, or null to keep the declared type. + * + * Unlike upstream, a model whose database attributes are not all known keeps the declared type. + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $types = []; + + foreach ($scope->getType($methodCall->var)->getObjectClassReflections() as $class) { + if (! $class->is(Model::class)) { + continue; + } + + if ($class->isAbstract() || $class->getName() === Model::class) { + return null; + } + + // Defer to serializer and configuration overrides without executing application methods. + foreach (array_unique([$methodReflection->getName(), 'attributesToArray', 'getAppends', 'getHidden', 'getVisible']) as $method) { + if ($class->getNativeMethod($method)->getDeclaringClass()->getName() !== Model::class) { + return null; + } + } + + $databaseNames = $this->properties->getDatabasePropertyNames($class); + + if ($databaseNames === null) { + return null; + } + + $appends = $this->configuredNames($class, Appends::class); + $hidden = $this->configuredNames($class, Hidden::class); + $visible = $this->configuredNames($class, Visible::class); + $names = array_unique(array_merge($databaseNames, $appends)); + + if ($names === []) { + return null; + } + + $array = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($names as $name) { + if (in_array($name, $hidden, true) || ($visible !== [] && ! in_array($name, $visible, true))) { + continue; + } + + $array->setOffsetValueType(new ConstantStringType($name), $this->attributeType($class, $name, in_array($name, $appends, true), $scope), true); + } + + // Partial selects omit columns; aggregates, aliases and loaded relations add keys. + $array->makeUnsealed(new StringType, new MixedType); + $types[] = $array->getArray(); + } + + return $types === [] ? null : TypeCombinator::union(...$types); + } + + /** + * Get the names a model configures through its property and the class attribute that Model::resolveClassAttribute() finds. + * + * @param class-string $attributeClass + * @return array + */ + private function configuredNames(ClassReflection $class, string $attributeClass): array + { + $defaults = $class->getNativeReflection()->getProperty(lcfirst(class_basename($attributeClass)))->getDefaultValue(); + $defaults = is_array($defaults) ? array_filter($defaults, is_string(...)) : []; + + do { + foreach ([$class, ...$class->getTraits()] as $source) { + $config = $source->getNativeReflection()->getAttributes($attributeClass)[0] ?? null; + + if ($config !== null) { + $arguments = array_values($config->getArguments()); + $columns = is_array($arguments[0] ?? null) ? $arguments[0] : $arguments; + + return array_unique(array_merge($defaults, array_filter($columns, is_string(...)))); + } + } + + $class = $class->getParentClass(); + } while ($class !== null); + + return $defaults; + } + + /** + * Get the type an attribute serializes to, following mutateAttributeForArray() and the cast and date handling. + */ + private function attributeType(ClassReflection $class, string $name, bool $appended, Scope $scope): Type + { + $cast = $this->casts->getCastForProperty($class, $name); + + // A cast that cannot be determined may be a class cast, which mutateAttributeForArray() applies even before an accessor. + if ($cast === null && $this->casts->hasCastForProperty($class, $name)) { + return new MixedType; + } + + $castReflection = $cast === null ? null : $this->casts->getCastClass($cast); + // Unlike property reads, serialization prefers an Attribute getter to a get{Name}Attribute() method. + $attributeGetter = $this->properties->getAttributeGetMutatorType($class, $name); + $getter = $attributeGetter ?? $this->properties->getGetMutatorType($class, $name); + $enumCast = false; + + // mutateAttributeForArray() serializes an attribute with an accessor, and every appended one. + if ($getter !== null || $appended) { + if ($castReflection !== null && ! $castReflection->isEnum()) { + // It reads a class cast before any accessor, without its serialize hook. An inbound caster leaves the raw value. + $type = $this->properties->getDatabaseProperty($class, $name)->getReadableType(); + $date = false; + } elseif ($getter !== null) { + $type = $getter; + // Only the Attribute getter branch serializes the dates it returns. + $date = $attributeGetter !== null; + } else { + // mutateAttribute() calls a get{Name}Attribute() method the model does not have. + return new MixedType; + } + + $cast = null; + } elseif ($this->properties->hasDatabaseProperty($class, $name)->yes()) { + $type = $this->properties->getDatabaseProperty($class, $name)->getReadableType(); + $date = true; + $enumCast = $castReflection?->isEnum() ?? false; + + if ($castReflection !== null && ! $enumCast && ! $castReflection->is(CastsInboundAttributes::class)) { + if (in_array($castReflection->getName(), [AsArrayObject::class, AsEncryptedArrayObject::class], true)) { + return TypeCombinator::addNull(new ArrayType(TypeCombinator::union(new IntegerType, new StringType), new MixedType)); + } + + if ($castReflection->is(Castable::class) && ! in_array($castReflection->getName(), [AsCollection::class, AsEncryptedCollection::class, AsStringable::class], true)) { + // A castUsing factory can choose a serializer dynamically. + return new MixedType; + } + + if ($castReflection->hasNativeMethod('serialize')) { + $serialized = $castReflection->getNativeMethod('serialize')->getVariants()[0]->getReturnType(); + // addCastAttributesToArray() serializes only a value that is set, so null stays null. + $type = $type->isNull()->no() ? $serialized : TypeCombinator::addNull($serialized); + $date = false; + } + } + } else { + return new MixedType; + } + + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($class, $scope, $cast, $date, $enumCast): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + if ($date && (new ObjectType(DateTimeInterface::class))->isSuperTypeOf($type)->yes()) { + if ($cast !== null && str_contains($cast, ':') && in_array(explode(':', $cast)[0], ['date', 'datetime', 'immutable_date', 'immutable_datetime'], true)) { + return new StringType; + } + + return $class->getNativeMethod('serializeDate')->getVariants()[0]->getReturnType(); + } + + // A collection that overrides toArray() serializes as the override declares. + if ((new ObjectType(Enumerable::class))->isSuperTypeOf($type)->yes() + && in_array($type->getMethod('toArray', $scope)->getDeclaringClass()->getName(), [Collection::class, LazyCollection::class], true)) { + $value = TypeTraverser::map($type->getIterableValueType(), static function (Type $item, callable $traverse) use ($scope): Type { + if ($item instanceof UnionType) { + return $traverse($item); + } + + return (new ObjectType(Arrayable::class))->isSuperTypeOf($item)->yes() + ? $item->getMethod('toArray', $scope)->getVariants()[0]->getReturnType() + : $item; + }); + + return new ArrayType($type->getIterableKeyType(), $value); + } + + if ((new ObjectType(Arrayable::class))->isSuperTypeOf($type)->yes()) { + return $type->getMethod('toArray', $scope)->getVariants()[0]->getReturnType(); + } + + if ($enumCast && $type->isEnum()->yes()) { + $values = []; + + foreach ($type->getEnumCases() as $case) { + $values[] = $case->getBackingValueType() ?? new ConstantStringType($case->getEnumCaseName()); + } + + return $values === [] ? new MixedType : TypeCombinator::union(...$values); + } + + return $type; + }); + } +} diff --git a/src/phpstan/src/ReturnTypes/RelationCollectionExtension.php b/src/phpstan/src/ReturnTypes/RelationCollectionExtension.php new file mode 100644 index 0000000000..0cbc6a4544 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/RelationCollectionExtension.php @@ -0,0 +1,82 @@ +getDeclaringClass()->getActiveTemplateTypeMap()->getType('TRelatedModel'); + + if ($modelType === null && $methodReflection->getDeclaringClass()->getName() === Builder::class) { + $modelType = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap()->getType('TModel'); + } + + return $modelType !== null + && $modelType->getObjectClassNames() !== [] + && CollectionHelper::isFrameworkQueryMethod($methodReflection); + } + + /** + * Return the related model's collection class in place of the declared Eloquent collection. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $returnType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + + if (! in_array(Collection::class, $returnType->getReferencedClasses(), true)) { + return null; + } + + return $this->collectionHelper->replaceCollectionsInType($returnType); + } +} diff --git a/src/phpstan/src/ReturnTypes/RequestUserExtension.php b/src/phpstan/src/ReturnTypes/RequestUserExtension.php new file mode 100644 index 0000000000..11c2ad0934 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/RequestUserExtension.php @@ -0,0 +1,81 @@ +getName() === 'user'; + } + + /** + * Get the user type of the requested guard, or of every guard. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + if ($methodReflection->getDeclaringClass()->getName() !== Request::class) { + return null; + } + + // Upstream reads one constant guard name, so user('') looked up an empty name + // instead of the default guard that the request's user resolver uses. + $authModels = $this->authGuardHelper->getUserModels( + $scope, + $this->authGuardHelper->getGuardNames($methodCall->getArgs()[0] ?? null, $scope, acceptsEnums: false), + ); + + if (count($authModels) === 0) { + return null; + } + + return TypeCombinator::addNull( + TypeCombinator::union(...array_map( + static fn (string $authModel): Type => new ObjectType($authModel), + $authModels, + )), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/StorageDynamicStaticMethodReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/StorageDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000000..aa6abf17bc --- /dev/null +++ b/src/phpstan/src/ReturnTypes/StorageDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,66 @@ +getName(), ['disk', 'drive'], strict: true); + } + + /** + * Get the type of the selected disk. + * + * @param DependencyTracker&Scope $scope + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + return $this->diskHelper->getDiskType( + $methodReflection->getName(), + $methodCall->getArgs()[0] ?? null, + $scope, + $methodReflection->getVariants()[0]->getReturnType(), + ); + } +} diff --git a/src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php new file mode 100644 index 0000000000..0745553ab3 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/TransHelperReturnTypeExtension.php @@ -0,0 +1,58 @@ +getName() === 'trans'; + } + + /** + * Get the translation type for a key that cannot be null or comes from unpacked lists. + */ + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type { + // Upstream returns the translator only for a call without arguments. An explicit + // or possibly null key keeps the helper's declared conditional return type. + $key = $functionCall->getArgs()[0] ?? null; + + if ($key !== null && $key->unpack) { + return $this->getUnpackedHelperType($functionReflection, $functionCall, $scope); + } + + if ($key === null || ! $scope->getType($key->value)->isNull()->no()) { + return null; + } + + return new BenevolentUnionType([ + new ArrayType(new MixedType, new MixedType), + new StringType, + ]); + } +} diff --git a/src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php b/src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php new file mode 100644 index 0000000000..a236197f26 --- /dev/null +++ b/src/phpstan/src/ReturnTypes/TranslatorGetReturnTypeExtension.php @@ -0,0 +1,67 @@ +getName() === 'get'; + } + + /** + * Get the translation type. + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $translation = new UnionType([ + new ArrayType(new MixedType, new MixedType), + new StringType, + ]); + + $declaredType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $methodReflection->getVariants(), + )->getReturnType(); + + // Upstream always returns the union, which widens a translator whose get() declares a narrower type. + if (! $declaredType->isSuperTypeOf($translation)->yes()) { + return null; + } + + return new BenevolentUnionType($translation->getTypes()); + } +} diff --git a/src/phpstan/src/Rules/ArrKeyExistenceRule.php b/src/phpstan/src/Rules/ArrKeyExistenceRule.php new file mode 100644 index 0000000000..ee1457d590 --- /dev/null +++ b/src/phpstan/src/Rules/ArrKeyExistenceRule.php @@ -0,0 +1,85 @@ + + */ +#[AutowiredService] +class ArrKeyExistenceRule implements Rule +{ + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Report the missing offset of an Arr::get() or Arr::pull() key that never exists. + * + * @param NodeCallbackInvoker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Identifier || ! $node->class instanceof Name) { + return []; + } + + $methodName = strtolower($node->name->name); + + if (! in_array($methodName, ['get', 'pull'], true) || ! ArrKeyLookup::appliesTo($scope, $node, [$methodName, ...ArrKeyLookup::GET_METHODS])) { + return []; + } + + $arrayArg = $node->getArg('array', 0); + $keyArg = $node->getArg('key', 1); + + // A default means the caller expects the key may be missing. + if ($arrayArg === null || $keyArg === null || $node->getArg('default', 2) !== null) { + return []; + } + + $lookups = ArrKeyLookup::find($scope, $arrayArg->value, $keyArg->value) ?? []; + + foreach ($lookups as $candidates) { + foreach ($candidates as [$found]) { + if (! $found->no()) { + return []; + } + } + } + + foreach ($lookups as $candidates) { + // The last candidate is the "dot" path when there is one. + [, $missing] = $candidates[count($candidates) - 1]; + $missing->setAttributes($node->getAttributes()); + $scope->invokeNodeCallback($missing); + } + + return []; + } +} diff --git a/src/phpstan/src/Rules/CheckDispatchArgumentTypesCompatibleWithClassConstructorRule.php b/src/phpstan/src/Rules/CheckDispatchArgumentTypesCompatibleWithClassConstructorRule.php new file mode 100644 index 0000000000..d809205926 --- /dev/null +++ b/src/phpstan/src/Rules/CheckDispatchArgumentTypesCompatibleWithClassConstructorRule.php @@ -0,0 +1,218 @@ + */ +class CheckDispatchArgumentTypesCompatibleWithClassConstructorRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private FunctionCallParametersCheck $check, + private string $dispatchableClass, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Check the dispatch arguments against the dispatched class's constructor. + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Node\Identifier) { + return []; + } + + // Unlike upstream, the method name matches in any case, as PHP calls it. + $calledName = $node->name->name; + $methodName = array_find($this->getAvailableMethods(), static fn (string $method): bool => strcasecmp($method, $calledName) === 0); + + if ($methodName === null) { + return []; + } + + if (! $node->class instanceof Node\Name) { + return []; + } + + $className = $scope->resolveName($node->class); + + if (! $this->reflectionProvider->hasClass($className)) { + return []; + } + + $calledClass = $this->reflectionProvider->getClass($className); + + // Unlike upstream, a class's own dispatch method decides its arguments itself. + if ( + ! $calledClass->hasNativeMethod($methodName) + || $calledClass->getNativeReflection()->getMethod($methodName)->getFileName() !== $this->reflectionProvider->getClass($this->dispatchableClass)->getFileName() + ) { + return []; + } + + $jobClassReflection = $calledClass; + + // Unlike upstream, a self, static or parent call forwards late static binding, so the method + // constructs the calling class, or a subclass with another constructor unless the calling class + // is final. PHPStan's new static check also follows final and consistent constructors; this does not. + if ($node->class->isSpecialClassName()) { + $jobClassReflection = $scope->getClassReflection(); + + if ($jobClassReflection === null || ! $jobClassReflection->isFinal()) { + return []; + } + } + + // Unlike upstream, which checks only a boolean condition, the constructor also runs before a + // closure condition. The condition is the first positional argument, or the named one. + if (in_array($methodName, ['dispatchIf', 'dispatchUnless'], true)) { + $args = $node->getArgs(); + + if ($args === [] || $args[0]->unpack) { + return []; + } + + $conditionArg = $args[0]->name === null ? $args[0] : null; + + foreach ($args as $arg) { + if ($arg->name?->name === 'boolean') { + $conditionArg = $arg; + } + } + + // PHPStan reports the missing condition. + if ($conditionArg === null) { + return []; + } + + // The constructor receives the other arguments, checked at the call's position. + $node = new StaticCall( + $node->class, + $node->name, + array_values(array_filter($args, static fn (Node\Arg $arg): bool => $arg !== $conditionArg)), + $node->getAttributes(), + ); + } + + $jobOrEvent = $this->dispatchableClass === Dispatchable::class ? 'job' : 'event'; + + if (! $jobClassReflection->hasConstructor()) { + if (count($node->getArgs()) > 0) { + return [ + RuleErrorBuilder::message(sprintf( + ucfirst($jobOrEvent) . ' class %s does not have a constructor and must be dispatched without any parameters.', + $jobClassReflection->getDisplayName(), + )) + ->identifier(sprintf('hypervel.%s.noConstructor', $jobOrEvent . 's')) + ->build(), + ]; + } + + return []; + } + + $constructorReflection = $jobClassReflection->getConstructor(); + + $classDisplayName = str_replace('%', '%%', $jobClassReflection->getDisplayName()); + + $variant = ParametersAcceptorSelector::selectFromArgs($scope, $node->getArgs(), $constructorReflection->getVariants()); + + // @phpstan-ignore phpstanApi.method (PHPStan's own argument check, which new and static calls use.) + return $this->check->check( + // Unlike upstream, a reference parameter takes any expression: the dispatch method passes its + // variadic arguments' values to the constructor, never the caller's variables. + ProxiedMethodReflection::proxyVariant($variant, $variant->getReturnType()), + $scope, + $constructorReflection->getDeclaringClass()->isBuiltin(), + $node, + 'staticMethod', + TrinaryLogic::createYes(), + ucfirst($jobOrEvent) . ' class ' . $classDisplayName . ' constructor invoked with %d parameter in ' . $classDisplayName . '::' . $methodName . '(), %d required.', + ucfirst($jobOrEvent) . ' class ' . $classDisplayName . ' constructor invoked with %d parameters in ' . $classDisplayName . '::' . $methodName . '(), %d required.', + ucfirst($jobOrEvent) . ' class ' . $classDisplayName . ' constructor invoked with %d parameter in ' . $classDisplayName . '::' . $methodName . '(), at least %d required.', + ucfirst($jobOrEvent) . ' class ' . $classDisplayName . ' constructor invoked with %d parameters in ' . $classDisplayName . '::' . $methodName . '(), at least %d required.', + ucfirst($jobOrEvent) . ' class ' . $classDisplayName . ' constructor invoked with %d parameter in ' . $classDisplayName . '::' . $methodName . '(), %d-%d required.', + ucfirst($jobOrEvent) . ' class ' . $classDisplayName . ' constructor invoked with %d parameters in ' . $classDisplayName . '::' . $methodName . '(), %d-%d required.', + '%s of ' . $jobOrEvent . ' class ' . $classDisplayName . ' constructor expects %s in ' . $classDisplayName . '::' . $methodName . '(), %s given.', + '', // constructor does not have a return type + '%s of ' . $jobOrEvent . ' class ' . $classDisplayName . ' constructor is passed by reference, so it expects variables only', + 'Unable to resolve the template type %s in instantiation of ' . $jobOrEvent . ' class ' . $classDisplayName, + 'Missing parameter $%s in call to ' . $classDisplayName . ' constructor.', + 'Unknown parameter $%s in call to ' . $classDisplayName . ' constructor.', + 'Return type of call to ' . $classDisplayName . ' constructor contains unresolvable type.', + '', + '', // no named arguments + '', + '', + '', + null, + null, // no callee reflection for parameter closure type extensions + ); + } + + /** + * Get the dispatch methods that construct the class from their arguments. + * + * @return non-empty-string[] + */ + private function getAvailableMethods(): array + { + // Upstream's dispatchNow() is omitted: the trait has no such method. + if ($this->dispatchableClass === Dispatchable::class) { + return [ + 'dispatch', + 'dispatchIf', + 'dispatchUnless', + 'dispatchSync', + 'dispatchAfterResponse', + ]; + } + + // Unlike upstream, broadcast() is checked too: it constructs the event the same way. + if ($this->dispatchableClass === EventDispatchable::class) { + return [ + 'dispatch', + 'dispatchIf', + 'dispatchUnless', + 'broadcast', + ]; + } + + return []; + } +} diff --git a/src/phpstan/src/Rules/ConfigCollectionRule.php b/src/phpstan/src/Rules/ConfigCollectionRule.php new file mode 100644 index 0000000000..d9a5e8308d --- /dev/null +++ b/src/phpstan/src/Rules/ConfigCollectionRule.php @@ -0,0 +1,75 @@ +collection(), + * are checked too. + * + * @implements Rule + */ +#[Conditional(attribute: new AutowiredService, if: '%checkConfigTypes%')] +class ConfigCollectionRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct(private ConfigHelper $configHelper) + { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report collection() calls for config values that are never arrays. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if ($this->configHelper->getMethodName($node, $scope) !== 'collection') { + return []; + } + + $key = $node->getArg('key', 0); + $valueType = $this->configHelper->getValueType($node, $scope); + + if ($key === null || $valueType === null || ! $valueType->isArray()->no()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Config key %s is not an array, so collection() throws. Correct the config value or use the getter that matches its type.', + $scope->getType($key->value)->describe(VerbosityLevel::value()), + )) + ->identifier('hypervel.configCollection') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/ConfigTypeRule.php b/src/phpstan/src/Rules/ConfigTypeRule.php new file mode 100644 index 0000000000..60891c8b0e --- /dev/null +++ b/src/phpstan/src/Rules/ConfigTypeRule.php @@ -0,0 +1,90 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkConfigTypes%')] +class ConfigTypeRule implements Rule +{ + /** + * The value types the typed getters require, as the error message names them. + */ + private const array GETTER_TYPES = [ + 'string' => 'a string', + 'integer' => 'an integer', + 'float' => 'a float', + 'boolean' => 'a boolean', + 'array' => 'an array', + ]; + + /** + * Create a new rule instance. + */ + public function __construct(private ConfigHelper $configHelper) + { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report typed getter calls for config values that never have the getter's type. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $methodName = $this->configHelper->getMethodName($node, $scope); + + if (! isset(self::GETTER_TYPES[$methodName])) { + return []; + } + + $key = $node->getArg('key', 0); + $valueType = $this->configHelper->getValueType($node, $scope); + + if ($key === null || $valueType === null || ! $this->configHelper->getGetterType($methodName)->isSuperTypeOf($valueType)->no()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Config key %s is not %s, so %s() throws. Correct the config value or use the getter that matches its type.', + $scope->getType($key->value)->describe(VerbosityLevel::value()), + self::GETTER_TYPES[$methodName], + $methodName, + )) + ->identifier('hypervel.configType') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php b/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php new file mode 100644 index 0000000000..14f3908adb --- /dev/null +++ b/src/phpstan/src/Rules/ConsoleCommand/UndefinedArgumentOrOptionRule.php @@ -0,0 +1,117 @@ + */ +#[AutowiredService] +class UndefinedArgumentOrOptionRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct(private ConsoleApplicationResolver $consoleApplicationResolver) + { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Report arguments and options that the command does not define. + * + * @param DependencyTracker&Scope $scope + * @return RuleError[] errors + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Identifier || ! in_array($node->name->toLowerString(), ['argument', 'option'], true)) { + return []; + } + + if (count($node->getArgs()) !== 1) { + return []; + } + + $receiverType = $scope->getType($node->var); + + if (! (new ObjectType(Command::class))->isSuperTypeOf($receiverType)->yes()) { + return []; + } + + $methodName = $node->name->toLowerString(); + + // Unlike upstream, a command's own override decides which names it accepts. + if ($scope->getMethodReflection($receiverType, $methodName)?->getDeclaringClass()->getName() !== Command::class) { + return []; + } + + $value = $node->getArgs()[0]->value; + + $argStrings = $scope->getType($value)->getConstantStrings(); + + if (count($argStrings) !== 1) { + return []; + } + + // A definitely true or unreachable hasArgument()/hasOption() guard keeps the call from commands + // without the name. The scope keys the guard as written, so check its positional and named forms. + $guardMethod = $methodName === 'argument' ? 'hasArgument' : 'hasOption'; + + foreach ([new Arg($value), new Arg($value, name: new Identifier('name'))] as $guardArg) { + if ($scope->getType(new MethodCall($node->var, $guardMethod, [$guardArg]))->isFalse()->no()) { + return []; + } + } + + $argName = $argStrings[0]->getValue(); + + $errors = []; + + // Upstream checks the enclosing command, which misreports calls on another command. + foreach ($this->consoleApplicationResolver->findCommandsForType($receiverType, $scope) as $command) { + $name = $command->getName(); + + if ($methodName === 'argument') { + if (! $command->getDefinition()->hasArgument($argName)) { + $errors[] = RuleErrorBuilder::message(sprintf('Command "%s" does not have argument "%s".', $name, $argName)) + ->line($node->getStartLine()) + ->identifier('hypervel.console.undefinedArgument') + ->build(); + } + } elseif (! $command->getDefinition()->hasOption($argName) && ! $command->getDefinition()->hasNegation($argName)) { + // Input::getOption() reads negations such as no-ansi, but throws for the shortcuts that upstream accepted. + $errors[] = RuleErrorBuilder::message(sprintf('Command "%s" does not have option "%s".', $name, $argName)) + ->line($node->getStartLine()) + ->identifier('hypervel.console.undefinedOption') + ->build(); + } + } + + return $errors; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/CancellationPrecedenceRule.php b/src/phpstan/src/Rules/Coroutine/CancellationPrecedenceRule.php new file mode 100644 index 0000000000..e6170c1236 --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/CancellationPrecedenceRule.php @@ -0,0 +1,179 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkCancellationPrecedence%')] +class CancellationPrecedenceRule implements Rule +{ + /** + * The statements that leave a guard's cancellation branch, so the statements after the guard see only ordinary failures. + */ + private const array EXITS = [Throw_::class, Return_::class, Continue_::class, Break_::class]; + + /** + * Create a new cancellation precedence rule instance. + */ + public function __construct( + private CoroutineRuleHelper $coroutineRuleHelper, + private ExprPrinter $exprPrinter, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return TryCatch::class; + } + + /** + * Report ??= assignments that may keep an ordinary failure over a caught exception. + * + * @param TryCatch $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + + foreach ($node->catches as $catch) { + if ($catch->var === null || ! is_string($catch->var->name)) { + continue; + } + + $variable = $catch->var->name; + + foreach ($this->findAssignments($catch->stmts, $variable) as $assignment) { + if ($this->keepsNothingButCancellation($assignment, $node, $catch, $scope)) { + continue; + } + + $kept = $this->exprPrinter->printExpr($assignment->var); + + $errors[] = RuleErrorBuilder::message(sprintf( + '%1$s ??= $%2$s keeps the first failure even when a later one carries this coroutine\'s cancellation. Use %1$s = Coroutine::preferCancellation(%1$s, $%2$s).', + $kept, + $variable, + )) + ->line($assignment->getStartLine()) + ->identifier('hypervel.cancellation.precedence') + ->build(); + } + } + + return $errors; + } + + /** + * Find the ??= assignments of the caught exception that can run when it carries cancellation. + * + * The ordinary-failure branches of a top-level Coroutine::causedByCancellation() guard, and the statements + * after a guard whose cancellation branch exits, never see a cancellation. + * + * @param array $statements + * + * @return list + */ + private function findAssignments(array $statements, string $variable): array + { + $reachable = []; + + foreach ($statements as $statement) { + if (! $statement instanceof If_ + || ($polarity = $this->coroutineRuleHelper->guardPolarity($statement->cond, $variable)) === null) { + $reachable[] = $statement; + + continue; + } + + if (! $polarity) { + foreach ($statement->elseifs as $elseif) { + array_push($reachable, ...$elseif->stmts); + } + + array_push($reachable, ...($statement->else->stmts ?? [])); + + continue; + } + + array_push($reachable, ...$statement->stmts); + + if ($this->coroutineRuleHelper->endsWith($statement->stmts, self::EXITS)) { + break; + } + } + + /** @var list $assignments */ + $assignments = $this->coroutineRuleHelper->find( + $reachable, + static fn (Node $node): bool => $node instanceof Coalesce + && $node->expr instanceof Variable + && $node->expr->name === $variable, + $variable, + ); + + return $assignments; + } + + /** + * Determine whether the kept value can only be nothing yet or a cancellation when the assignment runs. + * + * The type at the try's start holds at the assignment only for a local variable that the try and catch + * mention nowhere else, including in nested functions, closure use lists and by-reference arguments. + */ + private function keepsNothingButCancellation(Coalesce $assignment, TryCatch $node, Catch_ $catch, Scope $scope): bool + { + if (! $assignment->var instanceof Variable || ! is_string($assignment->var->name)) { + return false; + } + + $name = $assignment->var->name; + + $mentions = (new NodeFinder)->find( + [...$node->stmts, ...$catch->stmts], + static fn (Node $node): bool => $node instanceof Variable && $node->name === $name, + ); + + return count($mentions) === 1 + && (new ObjectType(CanceledException::class)) + ->isSuperTypeOf(TypeCombinator::removeNull($scope->getType($assignment->var))) + ->yes(); + } +} diff --git a/src/phpstan/src/Rules/Coroutine/CoroutineRuleHelper.php b/src/phpstan/src/Rules/Coroutine/CoroutineRuleHelper.php new file mode 100644 index 0000000000..62b06074ff --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/CoroutineRuleHelper.php @@ -0,0 +1,145 @@ +toLowerString() === strtolower(CanceledException::class); + } + + /** + * Get whether the condition is a Coroutine::causedByCancellation() guard on the variable (true), the guard + * negated (false), or neither (null). + */ + public function guardPolarity(Expr $condition, string $variable): ?bool + { + if ($condition instanceof BooleanNot) { + return $this->guardPolarity($condition->expr, $variable) === true ? false : null; + } + + if (! $condition instanceof StaticCall + || $condition->isFirstClassCallable() + || ! $condition->class instanceof Name + || ! $condition->name instanceof Identifier + || $condition->name->toLowerString() !== 'causedbycancellation') { + return null; + } + + $argument = $condition->getArgs()[0] ?? null; + $className = $condition->class->toString(); + + return $argument !== null + && ! $argument->unpack + && $argument->value instanceof Variable + && $argument->value->name === $variable + && $this->reflectionProvider->hasClass($className) + && $this->reflectionProvider->getClass($className)->is(Coroutine::class) + ? true + : null; + } + + /** + * Determine whether the statements end by leaving their block through one of the given statement or expression types. + * + * @param array $statements + * @param list> $exits + */ + public function endsWith(array $statements, array $exits): bool + { + $last = end($statements); + $node = $last instanceof Expression ? $last->expr : $last; + + foreach ($exits as $exit) { + if ($node instanceof $exit) { + return true; + } + } + + return false; + } + + /** + * Find the matching nodes in the statements, skipping the bodies of nested functions and classes, + * and of nested catches that bind the given variable again, since it names another exception there. + * + * @param array $statements + * @param Closure(Node): bool $filter + * + * @return list + */ + public function find(array $statements, Closure $filter, ?string $variable = null): array + { + $visitor = new class($filter, $variable) extends NodeVisitorAbstract { + /** @var list */ + public array $found = []; + + /** + * @param Closure(Node): bool $filter + */ + public function __construct( + private Closure $filter, + private ?string $variable, + ) { + } + + public function enterNode(Node $node): ?int + { + if ($node instanceof FunctionLike + || $node instanceof ClassLike + || ($node instanceof Catch_ && $this->variable !== null && $node->var?->name === $this->variable)) { + return NodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN; + } + + if (($this->filter)($node)) { + $this->found[] = $node; + } + + return null; + } + }; + + (new NodeTraverser($visitor))->traverse($statements); + + return $visitor->found; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/ExactCancellationCatchRule.php b/src/phpstan/src/Rules/Coroutine/ExactCancellationCatchRule.php new file mode 100644 index 0000000000..4d212f6c0a --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/ExactCancellationCatchRule.php @@ -0,0 +1,315 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkCancellationHandling%')] +class ExactCancellationCatchRule implements Rule +{ + /** + * The native wait methods, keyed by declaring class, that throw cancellation directly and so may keep an exact catch. + */ + private const array NATIVE_WAITS = [ + 'Swoole\Coroutine\Channel' => ['push', 'pop', 'close'], + 'Hypervel\Engine\Channel' => ['push', 'pop', 'close'], + 'Hypervel\Contracts\Engine\ChannelInterface' => ['push', 'pop', 'close'], + 'Swoole\Coroutine\WaitGroup' => ['add', 'done', 'wait'], + 'Hypervel\Coroutine\WaitGroup' => ['add', 'done', 'wait'], + 'Hypervel\Coroutine\Waiter' => ['wait'], + 'Swoole\Coroutine' => ['join'], + 'Hypervel\Coroutine\Coroutine' => ['join'], + 'Hypervel\Engine\Coroutine' => ['join'], + ]; + + /** + * Create a new exact cancellation catch rule instance. + */ + public function __construct( + private CoroutineRuleHelper $coroutineRuleHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return TryCatch::class; + } + + /** + * Report exact cancellation catches that rethrow or keep the cancellation, followed by a catch of every exception. + * + * @param TryCatch $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + + foreach ($node->catches as $index => $catch) { + if (! $this->passesCancellationOn($catch)) { + continue; + } + + $broadCatch = $this->findBroadCatch($node, $index); + + if ($broadCatch === null + || $this->keepsCancellationApart($broadCatch) + || $this->onlyWaitsNatively($node->stmts, $scope)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Catching CanceledException misses cancellation carried by another exception, which the catch on line %d handles as an ordinary failure. Check Coroutine::causedByCancellation() in that catch instead.', + $broadCatch->getStartLine(), + )) + ->line($catch->getStartLine()) + ->identifier('hypervel.cancellation.exactType') + ->addTip('A cleanup or callback failure in the try block can carry the cancellation as its previous exception.') + ->build(); + } + + return $errors; + } + + /** + * Determine whether the catch takes CanceledException and rethrows it, or keeps it in another variable for later. + */ + private function passesCancellationOn(Catch_ $catch): bool + { + if ($catch->var === null || ! is_string($catch->var->name)) { + return false; + } + + $catchesCancellation = false; + + foreach ($catch->types as $type) { + $catchesCancellation = $catchesCancellation || $this->coroutineRuleHelper->isCancellation($type); + } + + $variable = $catch->var->name; + + return $catchesCancellation && $this->coroutineRuleHelper->find( + $catch->stmts, + static fn (Node $node): bool => ($node instanceof Throw_ || $node instanceof Assign || $node instanceof Coalesce) + && $node->expr instanceof Variable + && $node->expr->name === $variable, + $variable, + ) !== []; + } + + /** + * Find the first catch after the given one that takes every exception. + */ + private function findBroadCatch(TryCatch $node, int $index): ?Catch_ + { + foreach ($node->catches as $laterIndex => $catch) { + if ($laterIndex <= $index) { + continue; + } + + foreach ($catch->types as $type) { + if (in_array($type->toLowerString(), ['throwable', 'exception'], true)) { + return $catch; + } + } + } + + return null; + } + + /** + * Determine whether the catch only rethrows its exception, or handles cancellation behind a + * Coroutine::causedByCancellation() guard that throws or that is the catch's only statement. + */ + private function keepsCancellationApart(Catch_ $catch): bool + { + if ($catch->var === null || ! is_string($catch->var->name)) { + return false; + } + + $variable = $catch->var->name; + $first = $catch->stmts[0] ?? null; + + if ($first instanceof Expression) { + return count($catch->stmts) === 1 + && $first->expr instanceof Throw_ + && $first->expr->expr instanceof Variable + && $first->expr->expr->name === $variable; + } + + if (! $first instanceof If_) { + return false; + } + + $polarity = $this->coroutineRuleHelper->guardPolarity($first->cond, $variable); + + return $polarity !== null + && (count($catch->stmts) === 1 + || ($polarity && $this->coroutineRuleHelper->endsWith($first->stmts, [Throw_::class]))); + } + + /** + * Determine whether every call the try block makes is a native channel, WaitGroup, Waiter or join wait. + * + * @param array $statements + */ + private function onlyWaitsNatively(array $statements, Scope $scope): bool + { + $calls = $this->coroutineRuleHelper->find( + $statements, + static fn (Node $node): bool => $node instanceof CallLike && ! $node->isFirstClassCallable(), + ); + + foreach ($calls as $call) { + $native = match (true) { + $call instanceof MethodCall, $call instanceof NullsafeMethodCall => $call->name instanceof Identifier + && ($receiverType = $this->getReceiverType($call->var, $statements, $scope)) !== null + && $this->isNativeWait($receiverType->getObjectClassReflections(), $call->name->toString()), + $call instanceof StaticCall => $call->class instanceof Name + && $call->name instanceof Identifier + && $this->isNativeWait($scope->resolveTypeByName($call->class)->getObjectClassReflections(), $call->name->toString()), + $call instanceof New_ => $call->class instanceof Name + && isset(self::NATIVE_WAITS[$scope->resolveName($call->class)]), + default => false, + }; + + if (! $native) { + return false; + } + } + + return true; + } + + /** + * Get the type a receiver has in the try block, or null when it is unknown. + * + * The type at the try's start holds for a local variable that the block only calls methods on. One + * construction, as a top-level statement before every call, gives it the constructed class; any other + * write leaves it unknown. + * + * @param array $statements + */ + private function getReceiverType(Expr $receiver, array $statements, Scope $scope): ?Type + { + if (! $receiver instanceof Variable || ! is_string($name = $receiver->name)) { + return $scope->getType($receiver); + } + + $nodes = $this->coroutineRuleHelper->find( + $statements, + static fn (Node $node): bool => ($node instanceof Variable && $node->name === $name) + || (($node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof Assign) + && $node->var instanceof Variable + && $node->var->name === $name), + ); + + $mentions = []; + $calls = []; + $assignments = []; + + foreach ($nodes as $node) { + if ($node instanceof Variable) { + $mentions[] = $node; + } elseif ($node instanceof Assign) { + $assignments[] = $node; + } else { + $calls[] = $node; + } + } + + if (count($mentions) !== count($calls) + count($assignments)) { + return null; + } + + if ($assignments === []) { + return $scope->getType($receiver); + } + + $assignment = $assignments[0]; + $construction = $assignment->expr; + + if (count($assignments) > 1 + || ! $construction instanceof New_ + || ! $construction->class instanceof Name + || ! array_any($statements, static fn (Node $statement): bool => $statement instanceof Expression && $statement->expr === $assignment)) { + return null; + } + + foreach ($calls as $call) { + if ($call->getStartFilePos() < $assignment->getEndFilePos()) { + return null; + } + } + + return new ObjectType($scope->resolveName($construction->class)); + } + + /** + * Determine whether the method, on every class it may be called on, is declared as a native wait. + * + * @param list $classReflections + */ + private function isNativeWait(array $classReflections, string $method): bool + { + foreach ($classReflections as $classReflection) { + if (! $classReflection->hasNativeMethod($method)) { + return false; + } + + $declaringClass = $classReflection->getNativeMethod($method)->getDeclaringClass()->getName(); + + if (! in_array(strtolower($method), self::NATIVE_WAITS[$declaringClass] ?? [], true)) { + return false; + } + } + + return $classReflections !== []; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/ExactCancellationCheckRule.php b/src/phpstan/src/Rules/Coroutine/ExactCancellationCheckRule.php new file mode 100644 index 0000000000..6a406bd0a4 --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/ExactCancellationCheckRule.php @@ -0,0 +1,76 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkCancellationHandling%')] +class ExactCancellationCheckRule implements Rule +{ + /** + * Create a new exact cancellation check rule instance. + */ + public function __construct( + private CoroutineRuleHelper $coroutineRuleHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return Instanceof_::class; + } + + /** + * Report exact cancellation checks of values that may be exceptions but are not already known to be cancellations. + * + * @param Instanceof_ $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->class instanceof Name || ! $this->coroutineRuleHelper->isCancellation($node->class)) { + return []; + } + + $subjectType = $scope->getType($node->expr); + $throwableType = new ObjectType(Throwable::class); + + if ($throwableType->isSuperTypeOf(TypeCombinator::removeNull($subjectType))->no() + || (new ObjectType(CanceledException::class))->isSuperTypeOf($subjectType)->yes()) { + return []; + } + + $error = RuleErrorBuilder::message('Checking instanceof CanceledException misses cancellation carried by another exception. Use Coroutine::causedByCancellation(), or Coroutine::preferCancellation() to choose between failures.') + ->identifier('hypervel.cancellation.exactType') + ->addTip('A cleanup or callback failure can carry the cancellation as its previous exception.'); + + if (! $throwableType->isSuperTypeOf($subjectType)->yes()) { + $error->addTip('Coroutine::causedByCancellation() takes a Throwable, so check "instanceof Throwable" first for a value that may be null or not an exception.'); + } + + return [$error->build()]; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/NoCoroutinesInTransactionsRule.php b/src/phpstan/src/Rules/Coroutine/NoCoroutinesInTransactionsRule.php new file mode 100644 index 0000000000..0b692806de --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/NoCoroutinesInTransactionsRule.php @@ -0,0 +1,350 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noCoroutinesInTransactions%')] +class NoCoroutinesInTransactionsRule implements Rule +{ + /** + * The lowercase short names of the functions and methods below. + */ + private const array SPAWNING_NAMES = [ + 'co' => true, + 'create' => true, + 'createowned' => true, + 'fork' => true, + 'forkowned' => true, + 'go' => true, + 'parallel' => true, + 'run' => true, + 'wait' => true, + ]; + + /** + * The functions that start child coroutines. + */ + private const array SPAWNING_FUNCTIONS = [ + 'hypervel\coroutine\co', + 'hypervel\coroutine\go', + 'hypervel\coroutine\parallel', + 'hypervel\coroutine\wait', + ]; + + /** + * The lowercase methods that start child coroutines, by declaring class. + */ + private const array SPAWNING_METHODS = [ + Coroutine::class => ['create', 'createowned', 'fork', 'forkowned'], + EngineCoroutine::class => ['create'], + SwooleCoroutine::class => ['create'], + Parallel::class => ['wait'], + Concurrent::class => ['create', 'fork'], + WaitConcurrent::class => ['create', 'fork'], + Waiter::class => ['wait'], + CoroutineDriver::class => ['run'], + ]; + + /** + * The names of the task-list parameters that spawning functions and methods take. + */ + private const array TASK_PARAMETERS = ['callables' => true, 'tasks' => true]; + + /** + * Create a new coroutines in transactions rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ConcurrencyDriverHelper $concurrencyDriverHelper, + private CoroutineRuleHelper $coroutineRuleHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report coroutine-starting calls that run inside a transaction callback. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + // Every call reaches this rule, so cheap name and call-stack checks come before reflection. + $name = $this->getCalledName($node); + + if ((! isset(self::SPAWNING_NAMES[$name]) && $name !== 'aftercommit') + || $node->isFirstClassCallable() + || ! $this->runsInTransaction($scope)) { + return []; + } + + $reflection = $this->getCalledReflection($node, $scope); + + if ($reflection === null) { + return []; + } + + $spawns = []; + + if ($this->callStartsCoroutine($node, $reflection, $scope)) { + $spawns[] = $node; + } elseif (! $this->runsTasksElsewhere($reflection, $scope) + && ($name !== 'aftercommit' || ! $reflection instanceof MethodReflection || ! $this->isDatabaseClass($reflection))) { + return []; + } + + // The arguments run now, in the transaction's coroutine, before the child starts, the tasks run or the + // callback is deferred. Calls in them find this call on their stack, so they are checked here instead. + $argumentCalls = $this->coroutineRuleHelper->find( + $node->getArgs(), + fn (Node $node): bool => $node instanceof CallLike + && ! $node->isFirstClassCallable() + && isset(self::SPAWNING_NAMES[$this->getCalledName($node)]), + ); + + foreach ($argumentCalls as $argumentCall) { + /** @var CallLike $argumentCall */ + $argumentReflection = $this->getCalledReflection($argumentCall, $scope); + + if ($argumentReflection !== null && $this->callStartsCoroutine($argumentCall, $argumentReflection, $scope)) { + $spawns[] = $argumentCall; + } + } + + return array_map( + static fn (CallLike $spawn): IdentifierRuleError => RuleErrorBuilder::message('A coroutine started inside a database transaction runs outside it: the child borrows its own pooled connection.') + ->line($spawn->getStartLine()) + ->identifier('hypervel.coroutineInTransaction') + ->addTip('Start the coroutine after the transaction commits, or run the work in the transaction\'s own coroutine.') + ->build(), + $spawns, + ); + } + + /** + * Get the lowercase short name of the function or method the call makes, or an empty string. + */ + private function getCalledName(CallLike $node): string + { + return strtolower(match (true) { + $node instanceof FuncCall && $node->name instanceof Name => $node->name->getLast(), + ($node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof StaticCall) + && $node->name instanceof Identifier => $node->name->toString(), + default => '', + }); + } + + /** + * Get the function or method the call makes, when it is known. + */ + private function getCalledReflection(CallLike $node, Scope $scope): FunctionReflection|MethodReflection|null + { + if ($node instanceof FuncCall) { + return $node->name instanceof Name && $this->reflectionProvider->hasFunction($node->name, $scope) + ? $this->reflectionProvider->getFunction($node->name, $scope) + : null; + } + + if (! ($node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof StaticCall) + || ! $node->name instanceof Identifier) { + return null; + } + + if ($node instanceof StaticCall) { + if (! $node->class instanceof Name) { + return null; + } + + $type = new ObjectType($scope->resolveName($node->class)); + } else { + $type = $scope->getType($node->var); + } + + return $scope->getMethodReflection($type, $node->name->toString()); + } + + /** + * Determine whether the call starts a child coroutine. + * + * @param DependencyTracker&Scope $scope + */ + private function callStartsCoroutine(CallLike $node, FunctionReflection|MethodReflection $reflection, Scope $scope): bool + { + return ! $this->passesNoTasks($node, $scope) && $this->startsCoroutine($reflection, $scope); + } + + /** + * Determine whether the call passes a task list known to be empty, which starts no coroutine. + */ + private function passesNoTasks(CallLike $node, Scope $scope): bool + { + $tasks = null; + + foreach ($node->getArgs() as $index => $argument) { + if ($argument->name === null ? $index === 0 : isset(self::TASK_PARAMETERS[$argument->name->toString()])) { + $tasks = $argument; + } + } + + if ($tasks === null || $tasks->unpack) { + return false; + } + + $type = $scope->getType($tasks->value); + + return $type->isArray()->yes() && $type->isIterableAtLeastOnce()->no(); + } + + /** + * Determine whether the function or method starts a child coroutine. The Concurrency facade's and + * manager's run() start one only when the default instance is known to use the coroutine driver. + * + * @param DependencyTracker&Scope $scope + */ + private function startsCoroutine(FunctionReflection|MethodReflection $reflection, Scope $scope): bool + { + if ($reflection instanceof FunctionReflection) { + return in_array(strtolower($reflection->getName()), self::SPAWNING_FUNCTIONS, true); + } + + $declaringClass = $reflection->getDeclaringClass(); + $method = strtolower($reflection->getName()); + + if ($method === 'run' && ($declaringClass->is(Concurrency::class) || $declaringClass->is(ConcurrencyManager::class))) { + return $this->concurrencyDriverHelper->getDriverClass(null, $scope) === CoroutineDriver::class; + } + + foreach (self::SPAWNING_METHODS as $class => $methods) { + if ($declaringClass->is($class) && in_array($method, $methods, true)) { + return true; + } + } + + return false; + } + + /** + * Determine whether the code runs in a transaction callback in its own coroutine. + * + * The innermost relevant call decides: an after-commit callback may run once the transaction + * commits, a child coroutine's own calls belong to the spawn reported around them, and tasks of + * a Concurrency run() not known to stay in place may run anywhere. A call whose arguments are + * being evaluated is on the stack too, so processNode() checks those itself. + * + * @param DependencyTracker&Scope $scope + */ + private function runsInTransaction(Scope $scope): bool + { + foreach (CallStack::enclosing($scope) as $reflection) { + if ($this->startsCoroutine($reflection, $scope) || $this->runsTasksElsewhere($reflection, $scope)) { + return false; + } + + if (! $reflection instanceof MethodReflection || ! $this->isDatabaseClass($reflection)) { + continue; + } + + $method = strtolower($reflection->getName()); + + if ($method === 'aftercommit') { + return false; + } + + if ($method === 'transaction') { + return true; + } + } + + return false; + } + + /** + * Determine whether the call is a Concurrency driver's run() that is not known to run its tasks in the + * calling coroutine. Only the sync driver runs them in place; a coroutine driver's run() starts children. + * + * @param DependencyTracker&Scope $scope + */ + private function runsTasksElsewhere(FunctionReflection|MethodReflection $reflection, Scope $scope): bool + { + if (! $reflection instanceof MethodReflection || strtolower($reflection->getName()) !== 'run') { + return false; + } + + $declaringClass = $reflection->getDeclaringClass(); + + if ($declaringClass->is(Concurrency::class) || $declaringClass->is(ConcurrencyManager::class)) { + return $this->concurrencyDriverHelper->getDriverClass(null, $scope) !== SyncDriver::class; + } + + return $declaringClass->is(Driver::class) && ! $declaringClass->is(SyncDriver::class); + } + + /** + * Determine whether the method belongs to a database connection, the manager or the DB facade. + */ + private function isDatabaseClass(MethodReflection $method): bool + { + $class = $method->getDeclaringClass(); + + return $class->is(ConnectionInterface::class) || $class->is(DatabaseManager::class) || $class->is(DB::class); + } +} diff --git a/src/phpstan/src/Rules/Coroutine/NoRawSleepCallsRule.php b/src/phpstan/src/Rules/Coroutine/NoRawSleepCallsRule.php new file mode 100644 index 0000000000..816d456ffd --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/NoRawSleepCallsRule.php @@ -0,0 +1,71 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noRawSleepCalls%')] +class NoRawSleepCallsRule implements Rule +{ + /** + * Create a new raw sleep calls rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * Report calls to the native sleep() and usleep() functions. + * + * @param FuncCall $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Name) { + return []; + } + + $function = strtolower($this->reflectionProvider->resolveFunctionName($node->name, $scope) ?? ''); + + if ($function !== 'sleep' && $function !== 'usleep') { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Call Sleep::%1$s() instead of %1$s(), so tests can fake the delay.', $function)) + ->identifier('hypervel.rawSleep') + ->addTip('Keep the native call only where real time must pass, such as polling an external process.') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/WorkerLifetimes.php b/src/phpstan/src/Rules/Coroutine/WorkerLifetimes.php new file mode 100644 index 0000000000..e1a41ffc2d --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/WorkerLifetimes.php @@ -0,0 +1,904 @@ + */ + private array $bindings; + + /** @var array */ + private array $instances; + + /** @var array */ + private array $scopedInstances; + + /** @var array> */ + private array $contextual; + + /** @var array> */ + private array $abstractAliases; + + /** @var array lowercase command classes the Bus maps to handlers */ + private array $mappedCommands = []; + + /** @var array abstract and implicit flag => [lifetime, class, whether the container passes the constructor arguments] */ + private array $lifetimes = []; + + /** @var array */ + private array $built = []; + + /** @var array the worker-shared classes */ + private array $receivers = []; + + /** @var null|array lowercase names of the worker-shared classes and their parents */ + private ?array $sharedClasses = null; + + /** @var array lowercase "class::method" => the operation it handles and whether a shared instance handles it */ + private array $contexts = []; + + /** @var list the receiver, declaring class, method, operation and whether the receiver is shared, of each context still to propagate */ + private array $pendingContexts = []; + + /** @var array lowercase "class::method::parameter" => the global configuration repository is injected */ + private array $configParameters = []; + + /** @var array lowercase "class::parameter" => whether every shared instance gets its default or a fresh value */ + private array $freshParameters = []; + + /** @var array the classes whose declarations the lifetimes depend on */ + private array $classes = []; + + /** + * Discover the worker lifetimes of the booted application and the collected roots. + * + * @param list $abstracts the abstracts resolved without parameters + * @param list $singletons the classes declaring #[Singleton] + * @param list $queued the concrete queued classes + * @param list $helperCalls the direct calls methods make on their own instance + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private Container $container, + array $abstracts, + array $singletons, + array $queued, + array $helperCalls, + ) { + $this->bindings = $container->getBindings(); + [$this->instances, $this->scopedInstances, $this->contextual, $this->abstractAliases] = (fn (): array => [ + $this->instances, + $this->scopedInstances, + $this->contextual, + $this->abstractAliases, + ])->call($container); + + $this->discoverBusHandlers(); + $this->discoverHttpRoots(); + $this->discoverListeners(); + + foreach ([...array_keys($this->bindings), ...array_keys($this->instances), ...$abstracts, ...$singletons] as $abstract) { + $this->make($abstract, null); + } + + foreach ($queued as $class) { + $this->discoverJob($class); + } + + // After every build, so whether a job instance is shared reflects every registration. + foreach ($queued as $class) { + $job = $this->getClass($class); + + if ($job === null) { + continue; + } + + foreach (WorkerStateHelper::JOB_METHODS as $method) { + if ($job->hasNativeMethod($method)) { + $declaringClass = $job->getNativeMethod($method)->getDeclaringClass()->getName(); + $this->addContext($job->getName(), $declaringClass, $method, 'job', isset($this->receivers[$job->getName()])); + } + } + } + + $this->propagateContexts($helperCalls); + } + + /** + * Determine whether the container shares the class, or a subclass of it, across requests. + */ + public function isShared(string $class): bool + { + if ($this->sharedClasses === null) { + $this->sharedClasses = []; + + foreach (array_keys($this->receivers) as $receiver) { + $this->sharedClasses[strtolower($receiver)] = true; + + foreach ($this->reflectionProvider->getClass($receiver)->getParentClassesNames() as $parent) { + $this->sharedClasses[strtolower($parent)] = true; + } + } + } + + return isset($this->sharedClasses[strtolower($class)]); + } + + /** + * Get the operation a booted middleware or controller action method handles, or null. + */ + public function getContext(string $class, string $method): ?string + { + return $this->contexts[strtolower($class . '::' . $method)][0] ?? null; + } + + /** + * Determine whether a worker-shared instance handles the booted middleware or controller action + * method, rather than only scoped or fresh ones. + */ + public function isContextShared(string $class, string $method): bool + { + return $this->contexts[strtolower($class . '::' . $method)][1] ?? false; + } + + /** + * Determine whether the container injects the global configuration repository into the parameter + * of a booted controller action or a job's handler. + */ + public function isConfigParameter(string $class, string $method, string $parameter): bool + { + return isset($this->configParameters[strtolower($class . '::' . $method . '::' . $parameter)]); + } + + /** + * Determine whether the container name resolves the global configuration repository, which the + * framework registers as "config" with its contract and class as aliases. + */ + public function isGlobalConfig(string $abstract): bool + { + return $this->container->getAlias($abstract) === 'config'; + } + + /** + * Determine whether every shared instance built with the class's constructor gets the parameter's + * default or a fresh value from the container. + */ + public function isFreshParameter(string $class, string $parameter): bool + { + return $this->freshParameters[strtolower($class . '::' . $parameter)] ?? false; + } + + /** + * Determine whether the class is a job whose handler the container calls with injected parameters. + */ + public function isJob(string $class): bool + { + $reflection = $this->getClass($class); + + return $reflection !== null + && ! isset($this->mappedCommands[strtolower($class)]) + && isset($reflection->getTraits(true)[Dispatchable::class]); + } + + /** + * Get the classes whose declarations the lifetimes were read from. + * + * @return list + */ + public function getClasses(): array + { + return array_keys($this->classes); + } + + /** + * Discover the Bus command handlers, which are resolved from the container and called directly + * with their commands. + */ + private function discoverBusHandlers(): void + { + if (! $this->container->bound(BusDispatcherContract::class)) { + return; + } + + $dispatcher = $this->container->make(BusDispatcherContract::class); + + if (! $dispatcher instanceof BusDispatcher) { + return; + } + + foreach ((fn (): array => $this->handlers)->call($dispatcher) as $command => $handler) { + $this->mappedCommands[strtolower($command)] = true; + + if (is_string($handler)) { + $this->make($handler, null); + } + } + } + + /** + * Discover the controllers, actions and middleware of the booted routes and HTTP kernel. + */ + private function discoverHttpRoots(): void + { + $router = $this->container->bound('router') ? $this->container->make('router') : null; + $kernel = $this->container->bound(Kernel::class) ? $this->container->make(Kernel::class) : null; + $middleware = []; + $aliases = []; + $groups = []; + + if ($kernel instanceof Kernel) { + $middleware = $kernel->getGlobalMiddleware(); + $aliases = $kernel->getMiddlewareAliases(); + $groups = $kernel->getMiddlewareGroups(); + } + + if ($router instanceof Router) { + $aliases = [...$aliases, ...$router->getMiddleware()]; + $groups = [...$groups, ...$router->getMiddlewareGroups()]; + + foreach ($router->getRoutes()->getRoutes() as $route) { + foreach ((array) ($route->getAction('middleware') ?? []) as $name) { + $middleware[] = $name; + } + + $this->discoverRoute($route, $router); + } + } + + // Every alias and group can be named by a route the analysis does not see, such as a + // controller's own middleware. + $names = array_unique(array_filter([...$middleware, ...array_values($aliases), ...array_keys($groups)], is_string(...))); + $classes = []; + + foreach ($names as $name) { + $resolved = MiddlewareNameResolver::resolve($name, $aliases, $groups); + + foreach (is_array($resolved) ? $resolved : [$resolved] as $entry) { + if (is_string($entry)) { + $classes[explode(':', $entry, 2)[0]] = true; + } + } + } + + foreach (array_keys($classes) as $class) { + $reflection = $this->getClass($class); + + if ($reflection === null) { + continue; + } + + [$shared, $receiver] = $this->makeHandler($reflection); + + foreach (['handle', 'terminate'] as $method) { + if ($receiver->hasNativeMethod($method)) { + $this->addContext($receiver->getName(), $receiver->getNativeMethod($method)->getDeclaringClass()->getName(), $method, 'middleware', $shared); + } + } + } + } + + /** + * Discover a route's controller and the dependencies its action receives. + */ + private function discoverRoute(Route $route, Router $router): void + { + $uses = $route->getAction('uses'); + + $actionClass = null; + + if ($uses instanceof Closure) { + $parameters = (new ReflectionFunction($uses))->getParameters(); + } elseif (is_string($uses)) { + [$class, $method] = str_contains($uses, '@') ? explode('@', $uses, 2) : [$uses, '__invoke']; + $reflection = $this->getClass($class); + + if ($reflection === null) { + return; + } + + [$shared, $receiver] = $this->makeHandler($reflection); + + if (! $receiver->hasNativeMethod($method)) { + return; + } + + $actionClass = $receiver->getNativeMethod($method)->getDeclaringClass()->getName(); + $this->addContext($receiver->getName(), $actionClass, $method, 'action', $shared); + $parameters = $receiver->getNativeReflection()->getMethod($method)->getParameters(); + } else { + return; + } + + $names = $route->parameterNames(); + + // An explicit route binder can return an object of any class. + $bound = array_any($names, static fn (string $name): bool => $router->getBindingCallback($name) !== null); + + foreach ($parameters as $parameter) { + $class = $this->getParameterClass($parameter); + + // ResolvesRouteDependencies::transformDependency() skips a class a route binding may supply, + // and never resolves a parameter with a default. ImplicitRouteBinding only binds a + // UrlRoutable parameter named like a route parameter, or its snake case. + if ($class === null + || $this->getContextualAttribute($parameter) !== null + || $parameter->isDefaultValueAvailable() + || $bound + || ($this->getClass($class)?->is(UrlRoutable::class) === true + && array_intersect([$parameter->getName(), Str::snake($parameter->getName())], $names) !== [])) { + continue; + } + + if ($actionClass !== null && $this->isGlobalConfig($class)) { + $this->configParameters[strtolower($actionClass . '::' . $method . '::' . $parameter->getName())] = true; + } + + $this->make($class, null); + } + } + + /** + * Resolve a middleware or controller class as the container builds it to handle a request, and + * return whether the instance is shared and its class, which may be a bound subclass. + * + * @return array{bool, ClassReflection} + */ + private function makeHandler(ClassReflection $class): array + { + [$lifetime, $built] = $this->make($class->getName(), null); + + return [$lifetime === self::SHARED, ($built === null ? null : $this->getClass($built)) ?? $class]; + } + + /** + * Record the operation a method handles for a receiver, and whether a worker-shared instance + * handles it, then queue it so the helpers it calls on the receiver handle the operation too. + */ + private function addContext(string $receiver, string $declaringClass, string $method, string $context, bool $shared): void + { + $key = strtolower($declaringClass . '::' . $method); + + $this->contexts[$key] = [$context, $shared || ($this->contexts[$key][1] ?? false)]; + $this->pendingContexts[] = [$receiver, $declaringClass, $method, $context, $shared]; + } + + /** + * Give each helper a context method calls on its receiver that context, along every chain of + * direct calls. + * + * @param list $helperCalls + */ + private function propagateContexts(array $helperCalls): void + { + $callees = []; + + foreach ($helperCalls as $call) { + $callees[strtolower($call['class'] . '::' . $call['method'])][strtolower($call['callee'])] = $call['callee']; + } + + // Whether each receiver's method was visited as shared: a shared visit covers a later + // unshared one, but not the other way round. + $visited = []; + + while (($pending = array_pop($this->pendingContexts)) !== null) { + [$receiver, $declaringClass, $method, $context, $shared] = $pending; + $key = strtolower($receiver . "\0" . $declaringClass . '::' . $method); + + if (array_key_exists($key, $visited) && ($visited[$key] || ! $shared)) { + continue; + } + + $visited[$key] = $shared; + + foreach ($callees[strtolower($declaringClass . '::' . $method)] ?? [] as $callee) { + $helper = $this->resolveHelper($receiver, $declaringClass, $callee); + + if ($helper !== null) { + $this->addContext($receiver, $helper->getDeclaringClass()->getName(), $helper->getName(), $context, $shared); + } + } + } + } + + /** + * Get the instance method that a call on $this in the caller's class runs for the receiver, or + * null when the call does not reach one. + * + * The caller's own private method runs whatever the receiver's class, as PHP binds private calls + * to the calling scope; any other call runs the receiver's implementation. + */ + private function resolveHelper(string $receiver, string $callerClass, string $name): ?ExtendedMethodReflection + { + $caller = $this->getClass($callerClass); + $method = null; + + if ($caller !== null && $caller->hasNativeMethod($name)) { + $own = $caller->getNativeMethod($name); + $method = $own->isPrivate() && $own->getDeclaringClass()->getName() === $caller->getName() ? $own : null; + } + + if ($method === null) { + $class = $this->getClass($receiver); + $method = $class !== null && $class->hasNativeMethod($name) ? $class->getNativeMethod($name) : null; + + // Another class's private method is out of the caller's reach. + if ($method === null || $method->isPrivate()) { + return null; + } + } + + return $method->isStatic() || strtolower($method->getName()) === '__construct' ? null : $method; + } + + /** + * Discover the class-string event listeners, which the dispatcher resolves from the container. + */ + private function discoverListeners(): void + { + $events = $this->container->bound('events') ? $this->container->make('events') : null; + + if (! $events instanceof EventDispatcher) { + return; + } + + $listeners = [...$events->getRawListeners(), ...(fn (): array => $this->wildcards)->call($events)]; + + foreach (array_merge(...array_values($listeners)) as $listener) { + $class = match (true) { + is_string($listener) => explode('@', $listener, 2)[0], + is_array($listener) && is_string($listener[0] ?? null) => $listener[0], + default => null, + }; + + if ($class !== null && $this->getClass($class) !== null) { + $this->make($class, null); + } + } + } + + /** + * Discover the dependencies the container injects into a job's handler. + * + * Only a class using the Bus Dispatchable trait is known to be a job: queued listeners and mapped + * commands are called directly, without injection. + */ + private function discoverJob(string $class): void + { + $reflection = $this->getClass($class); + + if ($reflection === null || ! $this->isJob($class)) { + return; + } + + $method = $reflection->hasNativeMethod('handle') ? 'handle' : '__invoke'; + + if (! $reflection->hasNativeMethod($method)) { + return; + } + + $declaringClass = $reflection->getNativeMethod($method)->getDeclaringClass()->getName(); + + foreach ($reflection->getNativeReflection()->getMethod($method)->getParameters() as $parameter) { + $dependency = $this->getParameterClass($parameter); + + // BoundMethod uses a default whenever the class is unbound, without checking contextual bindings. + if ($dependency === null + || $this->getContextualAttribute($parameter) !== null + || (! $parameter->isVariadic() && $parameter->isDefaultValueAvailable() && ! $this->container->bound($dependency))) { + continue; + } + + // A contextual binding for the job may replace the repository with another one. + $concrete = $this->getContextualConcrete($reflection->getName(), $this->container->getAlias($dependency)); + + if ($this->isGlobalConfig($dependency) && ($concrete === null || (is_string($concrete) && $this->isGlobalConfig($concrete)))) { + $this->configParameters[strtolower($declaringClass . '::' . $method . '::' . $parameter->getName())] = true; + } + + $this->make($dependency, $reflection->getName()); + } + } + + /** + * Resolve the abstract as make() does while building the consumer, walking the classes it + * builds, and return the lifetime of the result and the class it builds. + * + * @return array{string, null|string} + */ + private function make(string $abstract, ?string $consumer): array + { + $abstract = $this->container->getAlias($abstract); + + if ($consumer !== null && ($concrete = $this->getContextualConcrete($consumer, $abstract)) !== null) { + // A contextual build is never cached; a class name is resolved on its own, a closure is unknown. + return is_string($concrete) ? $this->make($concrete, null) : [self::UNKNOWN, null]; + } + + [$lifetime, $class, $knownArguments] = $this->getLifetime($abstract, true); + + if ($class !== null) { + $this->build($class, $lifetime, $knownArguments); + } + + return [$lifetime, $class]; + } + + /** + * Get the lifetime of the abstract's resolution, the class it builds and whether the container + * passes that class's constructor arguments. + * + * Without implicit lifetimes the abstract is a binding's concrete, which only an explicit + * lifetime separates from its binding. + * + * @return array{string, null|string, bool} + */ + private function getLifetime(string $abstract, bool $implicit): array + { + $key = $abstract . ($implicit ? '' : "\0concrete"); + + if (isset($this->lifetimes[$key])) { + return $this->lifetimes[$key]; + } + + // Bindings can be cyclic; the container fails for those, so the cycle is unknown. + $this->lifetimes[$key] = [self::UNKNOWN, null, false]; + + return $this->lifetimes[$key] = $this->computeLifetime($abstract, $implicit); + } + + /** + * Compute the lifetime of the abstract's resolution. + * + * @return array{string, null|string, bool} + */ + private function computeLifetime(string $abstract, bool $implicit): array + { + $binding = $this->bindings[$abstract] ?? null; + $concrete = $binding === null ? null : $this->getBindingConcrete($binding['concrete']); + + if (array_key_exists($abstract, $this->instances)) { + $instance = $this->instances[$abstract]; + + // An instance its binding built got its constructor arguments from the container. + return is_object($instance) + ? [self::SHARED, $instance::class, $concrete !== null && strtolower($concrete) === strtolower($instance::class)] + : [self::UNKNOWN, null, false]; + } + + if ($binding !== null) { + if ($concrete === null) { + return [self::UNKNOWN, null, false]; + } + + $lifetime = match (true) { + isset($this->scopedInstances[$abstract]) => self::SCOPED, + $binding['shared'] => self::SHARED, + default => self::FRESH, + }; + + if ($concrete === $abstract) { + $class = $this->getBuildableClass($concrete); + + return $class === null ? [self::UNKNOWN, null, false] : [$lifetime, $class->getName(), ! $class->is(SelfBuilding::class)]; + } + + [$concreteLifetime, $class, $knownArguments] = $this->getLifetime($this->container->getAlias($concrete), false); + + if ($concreteLifetime === self::UNKNOWN) { + return [self::UNKNOWN, null, false]; + } + + return [$lifetime === self::FRESH ? $concreteLifetime : $lifetime, $class, $knownArguments]; + } + + $class = $this->getBuildableClass($abstract); + + if ($class === null) { + return [self::UNKNOWN, null, false]; + } + + $native = $class->getNativeReflection(); + $selfBuilding = $class->is(SelfBuilding::class); + + return match (true) { + $native->getAttributes(Singleton::class) !== [] => [self::SHARED, $class->getName(), ! $selfBuilding], + $native->getAttributes(Scoped::class) !== [] => [self::SCOPED, $class->getName(), ! $selfBuilding], + $selfBuilding || $class->is(Transient::class) => [self::FRESH, $class->getName(), ! $selfBuilding], + default => match ($this->isExecutionScoped($class)) { + true => [self::SCOPED, $class->getName(), true], + null => [self::UNKNOWN, null, false], + false => [$implicit ? self::SHARED : self::FRESH, $class->getName(), true], + }, + }; + } + + /** + * Record a built class and walk the constructor dependencies the container resolves for it. + * + * A SelfBuilding class or an instance registered at boot gets its arguments elsewhere, so its + * dependencies are not walked and none of its parameters is known to be fresh. + */ + private function build(string $class, string $lifetime, bool $knownArguments): void + { + $shared = $lifetime === self::SHARED; + $key = strtolower($class) . "\0" . ($shared ? 'shared' : 'other') . "\0" . ($knownArguments ? 'known' : 'unknown'); + + if ($shared) { + $this->receivers[$class] = true; + } + + if (isset($this->built[$key])) { + return; + } + + $this->built[$key] = true; + $constructor = $this->getClass($class)?->getNativeReflection()->getConstructor(); + + if ($constructor === null) { + return; + } + + $declaringClass = $constructor->getDeclaringClass()->getName(); + + foreach ($constructor->getParameters() as $parameter) { + $outcome = $knownArguments ? $this->resolveConstructorParameter($parameter, $class) : self::UNKNOWN; + + if ($shared) { + $parameterKey = strtolower($declaringClass . '::' . $parameter->getName()); + $this->freshParameters[$parameterKey] = ($this->freshParameters[$parameterKey] ?? true) + && in_array($outcome, [self::DEFAULT, self::FRESH], true); + } + } + } + + /** + * Resolve a constructor parameter as Container::resolveRecipeParameters() does for the class being + * built, and return what it receives. + */ + private function resolveConstructorParameter(ReflectionParameter $parameter, string $consumer): string + { + if ($this->getContextualAttribute($parameter) !== null) { + return self::UNKNOWN; + } + + $class = $this->getParameterClass($parameter); + + if ($class === null) { + return $parameter->isDefaultValueAvailable() && ! isset($this->contextual[$consumer]['$' . $parameter->getName()]) + ? self::DEFAULT + : self::UNKNOWN; + } + + if ($parameter->isDefaultValueAvailable() + && ! $this->container->bound($class) + && ! isset($this->contextual[$consumer][$class])) { + return self::DEFAULT; + } + + if ($parameter->isVariadic()) { + $concrete = $this->getContextualConcrete($consumer, $this->container->getAlias($class)); + + if (is_array($concrete)) { + foreach ($concrete as $abstract) { + if (is_string($abstract)) { + $this->make($abstract, $consumer); + } + } + + return self::UNKNOWN; + } + } + + return $this->make($class, $consumer)[0]; + } + + /** + * Get the contextual binding the consumer has for the abstract, or one of its aliases. + */ + private function getContextualConcrete(string $consumer, string $abstract): mixed + { + $bindings = $this->contextual[$consumer] ?? []; + + foreach ([$abstract, ...$this->abstractAliases[$abstract] ?? []] as $name) { + if (isset($bindings[$name])) { + return $bindings[$name]; + } + } + + return null; + } + + /** + * Get the class a binding's closure builds when it was registered with a class name. + */ + private function getBindingConcrete(Closure $closure): ?string + { + $reflection = new ReflectionFunction($closure); + $concrete = $reflection->getStaticVariables()['concrete'] ?? null; + + // Container::getClosure() wraps a class-name concrete; any other closure is a factory. + return $reflection->getClosureScopeClass()?->getName() === Container::class && is_string($concrete) + ? $concrete + : null; + } + + /** + * Get the class the container can build for the name, or null when it is unknown, not + * instantiable, or bound by its own Bind or BindWhen attributes. + */ + private function getBuildableClass(string $name): ?ClassReflection + { + $class = $this->getClass($name); + + if ($class === null || ! $class->isClass() || $class->isAbstract()) { + return null; + } + + $native = $class->getNativeReflection(); + + return $native->getAttributes(Bind::class) === [] && $native->getAttributes(BindWhen::class) === [] ? $class : null; + } + + /** + * Determine whether a constructor attribute gives the class a lifetime scoped to the execution, or + * null when an attribute decides it at runtime in a way the analysis cannot read. + * + * Container::computeBuildRecipe() constructs each attribute and asks it. The framework's + * attributes decide from their class or constant arguments, which are read without constructing. + */ + private function isExecutionScoped(ClassReflection $class): ?bool + { + $scoped = false; + + foreach ($class->getNativeReflection()->getConstructor()?->getParameters() ?? [] as $parameter) { + $attribute = $this->getContextualAttribute($parameter); + $attributeClass = $attribute === null ? null : $this->getClass($attribute->getName()); + + if ($attributeClass === null || ! $attributeClass->is(ExecutionScopedAttribute::class)) { + continue; + } + + $decidedBy = $attributeClass->getNativeMethod('isExecutionScoped')->getDeclaringClass()->getName(); + $arguments = $decidedBy === Cache::class ? $attribute->getArguments() : []; + $memo = $arguments['memo'] ?? $arguments[1] ?? false; + + $decision = match ($decidedBy) { + Authenticated::class, Context::class, Database::class, RequestAttribute::class, RouteParameter::class => true, + Cache::class => is_bool($memo) ? $memo : null, + default => null, + }; + + if ($decision === true) { + return true; + } + + $scoped = $decision === null ? null : $scoped; + } + + return $scoped; + } + + /** + * Get the parameter's first contextual attribute, which supplies its value. + */ + private function getContextualAttribute(ReflectionParameter $parameter): ?ReflectionAttribute + { + foreach ($parameter->getAttributes() as $attribute) { + if ($this->getClass($attribute->getName())?->is(ContextualAttribute::class) === true) { + return $attribute; + } + } + + return null; + } + + /** + * Get the class a parameter's type names, as Container\Util::getParameterClassName() does. + */ + private function getParameterClass(ReflectionParameter $parameter): ?string + { + $type = $parameter->getType(); + + if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) { + return null; + } + + $declaringClass = $parameter->getDeclaringClass(); + + return match ($type->getName()) { + 'self' => $declaringClass?->getName(), + 'parent' => ($declaringClass?->getParentClass() ?: null)?->getName(), + default => $type->getName(), + }; + } + + /** + * Get the PHPStan reflection of the class, recording it as a dependency, or null when it is unknown. + */ + private function getClass(string $name): ?ClassReflection + { + if (! $this->reflectionProvider->hasClass($name)) { + return null; + } + + $class = $this->reflectionProvider->getClass($name); + $this->classes[$class->getName()] = true; + + return $class; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/WorkerStateHelper.php b/src/phpstan/src/Rules/Coroutine/WorkerStateHelper.php new file mode 100644 index 0000000000..53ba7f563b --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/WorkerStateHelper.php @@ -0,0 +1,357 @@ +, parameter: null|string, function: FunctionFacts} + * @phpstan-type RouteAction array{type: 'action', start: int, end: int, nested: list, configParameters: array} + * @phpstan-type HelperCall array{type: 'call', class: string, method: string, callee: string} + */ +#[AutowiredService] +class WorkerStateHelper +{ + /** + * The lowercase names of the methods that run a queued class as a job. + */ + public const array JOB_METHODS = ['handle', '__invoke', 'failed']; + + /** + * The values that belong to one request or operation, keyed by the label diagnostics use. + */ + private const array STATE_TYPES = [ + 'request' => [Request::class, ServerRequestInterface::class], + 'response' => [Response::class, ResponseInterface::class], + 'user' => [Authenticatable::class], + 'file' => [UploadedFile::class], + 'model' => [Model::class], + ]; + + /** + * The resolving helpers, each with its abstract's parameter name and whether parameters follow. + */ + private const array RESOLVING_FUNCTIONS = ['app' => ['abstract', true], 'resolve' => ['name', true]]; + + /** + * The resolving container methods, each with its abstract's parameter name and whether parameters follow. + */ + private const array RESOLVING_METHODS = ['make' => ['abstract', true], 'get' => ['id', false], 'factory' => ['abstract', false]]; + + /** + * The documented warning tags of boot-time and test-only mutators. + */ + private const string LIFETIME_TAG_PATTERN = '/^\s*(?:\/\*\*|\*)?\s*(?:Boot-only|Tests only|Boot or tests only)\b/m'; + + /** + * Create a new worker state helper instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private CoroutineRuleHelper $coroutineRuleHelper, + #[AutowiredParameter(ref: '%checkWorkerSharedState%')] + public readonly bool $checkWorkerSharedState, + #[AutowiredParameter(ref: '%checkStaticStateMutation%')] + public readonly bool $checkStaticStateMutation, + #[AutowiredParameter(ref: '%checkUnboundedWorkerCaches%')] + public readonly bool $checkUnboundedWorkerCaches, + #[AutowiredParameter(ref: '%noRuntimeConfigMutation%')] + public readonly bool $noRuntimeConfigMutation, + ) { + } + + /** + * Determine whether any worker-state check is enabled. + */ + public function isEnabled(): bool + { + return $this->checkWorkerSharedState + || $this->checkStaticStateMutation + || $this->checkUnboundedWorkerCaches + || $this->noRuntimeConfigMutation; + } + + /** + * Get the facts of the function the scope's code at the node runs in, or null when it is a + * flushState() method or one documented as boot-time or test-only. + * + * The context is the operation the code always runs in when the class alone decides it: a queued + * class's handle(), __invoke() or failed(), or a form request's method. The rule adds booted + * middleware and controller actions, the helpers those methods call on their own instance, and + * route closures by the node's position. + * + * @return null|FunctionFacts + */ + public function getFunctionFacts(Scope $scope, Node $node): ?array + { + $function = $scope->getFunction(); + + if ($function !== null && ($function->getName() === 'flushState' || $this->hasLifetimeTag($function->getDocComment()))) { + return null; + } + + $class = $scope->getClassReflection(); + $method = $function instanceof MethodReflection ? $function : null; + $closure = $scope->isInAnonymousFunction(); + + $context = match (true) { + $closure, $class === null, $method === null, $method->isStatic() => null, + $class->is(ShouldQueue::class) && in_array(strtolower($method->getName()), self::JOB_METHODS, true) => 'job', + $class->is(FormRequest::class) => 'request', + default => null, + }; + + return [ + 'class' => $class?->getName(), + 'method' => $method?->getName(), + 'closure' => $closure, + 'context' => $context, + 'position' => $node->getStartFilePos(), + ]; + } + + /** + * Determine whether the docblock documents its method as boot-time or test-only. + */ + public function hasLifetimeTag(?string $docComment): bool + { + return preg_match(self::LIFETIME_TAG_PATTERN, $docComment ?? '') === 1; + } + + /** + * Determine whether the type is always a configuration repository, which the rule may prove to be + * the global one. + */ + public function isConfigRepositoryType(Type $type): bool + { + return (new ObjectType(Repository::class))->isSuperTypeOf(TypeCombinator::removeNull($type))->yes(); + } + + /** + * Determine whether the call is the global config() helper. + */ + public function isConfigFunction(FuncCall $call, Scope $scope): bool + { + return $call->name instanceof Name + && strtolower($this->reflectionProvider->resolveFunctionName($call->name, $scope) ?? '') === 'config'; + } + + /** + * Describe the expression a configuration write goes through when it may be the global repository: + * config() without arguments, a constant container resolution (its names, which the rule resolves + * with the booted aliases), or a parameter of the innermost function typed as a repository (its + * name, which the rule checks the container injects). Null for any other expression. + * + * @return null|array{abstracts: null|list, parameter: null|string} + */ + public function getConfigReceiver(Expr $expr, Scope $scope): ?array + { + if ($expr instanceof FuncCall && $expr->getArgs() === [] && $this->isConfigFunction($expr, $scope)) { + return ['abstracts' => null, 'parameter' => null]; + } + + $abstracts = $expr instanceof CallLike ? $this->getResolvedAbstracts($expr, $scope) : null; + + if ($abstracts !== null) { + return ['abstracts' => $abstracts, 'parameter' => null]; + } + + $parameter = $expr instanceof Variable && is_string($expr->name) + ? array_find($this->getParameters($scope), static fn (ParameterReflection $parameter): bool => $parameter->getName() === $expr->name) + : null; + + return $parameter !== null && $this->isConfigRepositoryType($parameter->getType()) + ? ['abstracts' => null, 'parameter' => $parameter->getName()] + : null; + } + + /** + * Get the parameters of the scope's innermost function. + * + * @return list + */ + public function getParameters(Scope $scope): array + { + return $scope->isInAnonymousFunction() + ? $scope->getAnonymousFunctionReflection()->getParameters() + : $scope->getFunction()?->getOnlyVariant()->getParameters() ?? []; + } + + /** + * Get the abstracts a container resolution without parameters requests, or null for any other call. + * + * @return null|list + */ + public function getResolvedAbstracts(CallLike $call, Scope $scope): ?array + { + if ($call->isFirstClassCallable()) { + return null; + } + + $signature = match (true) { + $call instanceof FuncCall => $call->name instanceof Name + ? self::RESOLVING_FUNCTIONS[strtolower($this->reflectionProvider->resolveFunctionName($call->name, $scope) ?? '')] ?? null + : null, + $call instanceof MethodCall => $call->name instanceof Identifier + && (new ObjectType(Container::class))->isSuperTypeOf($scope->getType($call->var))->yes() + ? self::RESOLVING_METHODS[$call->name->toLowerString()] ?? null + : null, + $call instanceof StaticCall => $call->class instanceof Name + && $call->name instanceof Identifier + && (new ObjectType(App::class))->isSuperTypeOf(new ObjectType($scope->resolveName($call->class)))->yes() + ? self::RESOLVING_METHODS[$call->name->toLowerString()] ?? null + : null, + default => null, + }; + + if ($signature === null || array_any($call->getArgs(), static fn (Arg $argument): bool => $argument->unpack)) { + return null; + } + + // Arguments are read by name, as named arguments may come in any order. + $abstract = $call->getArg($signature[0], 0); + $parameters = $signature[1] ? $call->getArg('parameters', 1) : null; + + // Parameters bypass the shared caches, so such a resolution makes nothing worker-shared. An empty array does not. + if ($abstract === null || ($parameters !== null && ! $scope->getType($parameters->value)->isIterableAtLeastOnce()->no())) { + return null; + } + + $abstracts = $scope->getType($abstract->value)->getConstantStrings(); + + return $abstracts === [] ? null : array_map(static fn ($abstract): string => $abstract->getValue(), $abstracts); + } + + /** + * Get the label of the request or operation value the type always holds, ignoring null. + */ + public function getState(Type $type): ?string + { + $type = TypeCombinator::removeNull($type); + + if (! $type->isObject()->yes()) { + return null; + } + + foreach (self::STATE_TYPES as $label => $classes) { + foreach ($classes as $class) { + if ((new ObjectType($class))->isSuperTypeOf($type)->yes()) { + return $label; + } + } + } + + return null; + } + + /** + * Determine whether the expression reads the current request or user, without entering closures. + */ + public function readsRequestState(Expr $expr, Scope $scope): bool + { + $requestTypes = [...self::STATE_TYPES['request'], ...self::STATE_TYPES['user']]; + + return $this->coroutineRuleHelper->find([$expr], function (Node $node) use ($scope, $requestTypes): bool { + if (! $node instanceof Expr) { + return false; + } + + if ($node instanceof FuncCall + && $node->name instanceof Name + && in_array(strtolower($this->reflectionProvider->resolveFunctionName($node->name, $scope) ?? ''), ['request', 'auth'], true)) { + return true; + } + + if ($node instanceof StaticCall && $node->class instanceof Name) { + $class = new ObjectType($scope->resolveName($node->class)); + + if ((new ObjectType(RequestFacade::class))->isSuperTypeOf($class)->yes() || (new ObjectType(Auth::class))->isSuperTypeOf($class)->yes()) { + return true; + } + } + + $type = TypeCombinator::removeNull($scope->getType($node)); + + if (! $type->isObject()->yes()) { + return false; + } + + foreach ($requestTypes as $class) { + if ((new ObjectType($class))->isSuperTypeOf($type)->yes()) { + return true; + } + } + + return false; + }) !== []; + } + + /** + * Determine whether a key of the type can only take a bounded set of values. + */ + public function isBoundedKey(Type $type): bool + { + foreach (TypeUtils::flattenTypes($type) as $member) { + $range = TypeUtils::getIntegerRanges($member)[0] ?? null; + + // Bounds come first: PHPStan enumerates a small range value by value, and gives no finite + // types for a larger one. + if ($range?->getMin() !== null && $range->getMax() !== null) { + continue; + } + + if ($member->getFiniteTypes() === []) { + return false; + } + } + + return true; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/WorkerStateRule.php b/src/phpstan/src/Rules/Coroutine/WorkerStateRule.php new file mode 100644 index 0000000000..78ddb1b592 --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/WorkerStateRule.php @@ -0,0 +1,433 @@ + + */ +#[AutowiredService] +class WorkerStateRule implements Rule +{ + use HasContainer; + + /** + * How diagnostics describe each kind of request or operation state. + */ + private const array STATE_LABELS = [ + 'request' => 'the current request', + 'response' => 'a response', + 'user' => 'a user', + 'file' => 'an uploaded file', + 'model' => 'a model', + ]; + + private const string SHARED_TIP = 'Pass request values to the methods that use them or keep them in CoroutineContext. If the whole object belongs to one request, mark it #[Scoped].'; + + private const string STATIC_TIP = 'Keep per-request values in CoroutineContext, and set static state only during boot.'; + + private const string CACHE_TIP = 'Use a cache store with an expiry, or keep per-request values in CoroutineContext.'; + + private const string CONFIG_TIP = 'Set configuration during boot, and keep per-request values in CoroutineContext.'; + + /** + * Create a new worker state rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private WorkerStateHelper $helper, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * Report the collected writes and config changes that keep request state for the worker. + * + * @param CollectedDataNode $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $this->helper->isEnabled()) { + return []; + } + + // Lifetimes come from the booted bindings, routes, middleware, listeners and Bus handlers. + $this->runtimeInputs->track($scope); + + $classes = $node->get(WorkerStateClassCollector::class); + $configWrites = $node->get(WorkerStateConfigOffsetCollector::class); + $abstracts = []; + $singletons = []; + $queued = []; + $actions = []; + $helperCalls = []; + + foreach ($node->get(WorkerStateCallCollector::class) as $file => $calls) { + foreach ($calls as $call) { + if ($call['type'] === 'resolution') { + $abstracts += array_fill_keys($call['abstracts'], true); + } elseif ($call['type'] === 'action') { + $actions[$file][] = $call; + } elseif ($call['type'] === 'call') { + $helperCalls[] = $call; + } else { + $configWrites[$file][] = $call; + } + } + } + + foreach ($classes as $fileClasses) { + foreach ($fileClasses as $class) { + if ($class['singleton']) { + $singletons[] = $class['class']; + } + + if ($class['queued']) { + $queued[] = $class['class']; + } + } + } + + // The first position each method or route action reassigns each parameter at. + $reassigned = []; + + foreach ($node->get(WorkerStateParameterCollector::class) as $file => $parameters) { + foreach ($parameters as $parameter) { + $key = $this->getFunctionKey($parameter['function'], $file, $actions[$file] ?? []); + $position = $parameter['function']['position']; + + if ($key !== null && $position < ($reassigned[$key][$parameter['parameter']] ?? PHP_INT_MAX)) { + $reassigned[$key][$parameter['parameter']] = $position; + } + } + } + + // The analysis always runs on the framework container: the booted application or a test's own. + /** @var Container $container */ + $container = $this->getContainer(); + $lifetimes = new WorkerLifetimes($this->reflectionProvider, $container, array_keys($abstracts), $singletons, $queued, $helperCalls); + $errors = []; + + foreach ($node->get(WorkerStateWriteCollector::class) as $file => $writes) { + foreach ($writes as $write) { + $context = $this->getContext($write['function'], $actions[$file] ?? [], $lifetimes); + + // A constructor parameter keeps no state when every shared build passes a fresh value + // and the constructor writes it before giving it another one. + $fresh = $write['parameter'] !== null + && ! $this->isReassigned($write['function'], $write['parameter'], $file, $actions[$file] ?? [], $reassigned) + && $lifetimes->isFreshParameter($write['class'], $write['parameter']); + + $diagnostic = $this->checkWrite($write, $context, $fresh ? null : $write['state'], $lifetimes); + + if ($diagnostic !== null) { + $errors[] = $this->buildError($diagnostic, $file, $write['line']); + } + } + } + + foreach ($classes as $file => $fileClasses) { + foreach ($fileClasses as $class) { + if (! $this->helper->checkWorkerSharedState || ! $lifetimes->isShared($class['class'])) { + continue; + } + + foreach ($class['captures'] as $capture) { + if (! $lifetimes->isFreshParameter($class['class'], $capture['property'])) { + $errors[] = $this->buildError($this->sharedState($class['class'], $capture['property'], $capture['state']), $file, $capture['line']); + } + } + } + } + + if ($this->helper->noRuntimeConfigMutation) { + foreach ($configWrites as $file => $writes) { + foreach ($writes as $write) { + $context = $this->getContext($write['function'], $actions[$file] ?? [], $lifetimes); + + // A resolution is the global repository when the booted aliases say so, and a parameter + // where the container injects it and the function still holds it. + if ($context === null + || ($write['abstracts'] !== null && ! array_all($write['abstracts'], $lifetimes->isGlobalConfig(...))) + || ($write['parameter'] !== null && ! $this->isInjectedConfig($write['function'], $write['parameter'], $file, $actions[$file] ?? [], $reassigned, $lifetimes))) { + continue; + } + + $errors[] = $this->buildError([ + 'identifier' => 'hypervel.runtimeConfigMutation', + 'message' => sprintf('Configuration is changed %s, which changes it for the whole worker.', $this->describeContext($context)), + 'tip' => self::CONFIG_TIP, + ], $file, $write['line']); + } + } + } + + // Reused errors stay valid only while the classes the lifetimes were read from are unchanged. + foreach ($lifetimes->getClasses() as $class) { + $scope->trackClassDependency($class); + } + + return $errors; + } + + /** + * Get the diagnostic for a collected property write, choosing an unbounded cache before shared + * or static state, or null when the write is fine. + * + * @param array{static: bool, class: string, property: string, line: int, function: FunctionFacts, state: null|string, parameter: null|string, cacheKey: bool} $write + * @param null|string $state the state the write keeps, unless it is a fresh constructor parameter + * + * @return null|Diagnostic + */ + private function checkWrite(array $write, ?string $context, ?string $state, WorkerLifetimes $lifetimes): ?array + { + $shared = ! $write['static'] && $lifetimes->isShared($write['class']); + + if ($this->helper->checkUnboundedWorkerCaches && $write['cacheKey'] && ($write['static'] || $shared)) { + return [ + 'identifier' => 'hypervel.unboundedWorkerCache', + 'message' => sprintf( + '%s %s::$%s is keyed by request or user data, so it can grow for the life of the worker.', + $write['static'] ? 'Static property' : 'Property', + $write['class'], + $write['property'], + ), + 'tip' => self::CACHE_TIP, + ]; + } + + if (! $write['static'] && $this->helper->checkWorkerSharedState) { + if ($shared && $state !== null) { + return $this->sharedState($write['class'], $write['property'], $state); + } + + // A booted middleware or action is judged by the instances that handle it, which may be + // other subclasses than the shared ones. + $handledShared = $write['function']['closure'] || $write['function']['context'] !== null + ? $shared + : $write['function']['method'] !== null && $lifetimes->isContextShared($write['class'], $write['function']['method']); + + if ($context !== null && $handledShared && $write['function']['method'] !== null) { + return [ + 'identifier' => 'hypervel.workerSharedState', + 'message' => sprintf( + '%s::%s() writes $this->%s %s, but the container shares %s across concurrent requests.', + $write['class'], + $write['function']['method'], + $write['property'], + $this->describeContext($context), + $write['class'], + ), + 'tip' => 'Keep per-request values in local variables or CoroutineContext.', + ]; + } + } + + if ($write['static'] && $this->helper->checkStaticStateMutation) { + if ($state !== null) { + return [ + 'identifier' => 'hypervel.staticStateMutation', + 'message' => sprintf('Static property %s::$%s keeps %s, which the whole worker then shares.', $write['class'], $write['property'], self::STATE_LABELS[$state]), + 'tip' => self::STATIC_TIP, + ]; + } + + if ($context !== null) { + return [ + 'identifier' => 'hypervel.staticStateMutation', + 'message' => sprintf( + 'Static property %s::$%s is written %s, but static properties are shared by the whole worker.', + $write['class'], + $write['property'], + $this->describeContext($context), + ), + 'tip' => self::STATIC_TIP, + ]; + } + } + + return null; + } + + /** + * Get the diagnostic for a worker-shared property that keeps request or operation state. + * + * @return Diagnostic + */ + private function sharedState(string $class, string $property, string $state): array + { + return [ + 'identifier' => 'hypervel.workerSharedState', + 'message' => sprintf('Property %s::$%s keeps %s, but the container shares %s for the whole worker.', $class, $property, self::STATE_LABELS[$state], $class), + 'tip' => self::SHARED_TIP, + ]; + } + + /** + * Build the error for a diagnostic at the file and line. + * + * @param Diagnostic $diagnostic + */ + private function buildError(array $diagnostic, string $file, int $line): IdentifierRuleError + { + return RuleErrorBuilder::message($diagnostic['message']) + ->tip($diagnostic['tip']) + ->identifier($diagnostic['identifier']) + ->file($file) + ->line($line) + ->build(); + } + + /** + * Get the operation the function always handles: its collected context, the route action it is, + * a booted middleware or controller action method, or a helper such a method or a job calls. + * + * @param FunctionFacts $function + * @param list $actions the file's route actions + */ + private function getContext(array $function, array $actions, WorkerLifetimes $lifetimes): ?string + { + if ($function['closure']) { + return $this->findAction($function, $actions) === null ? null : 'action'; + } + + if ($function['context'] !== null || $function['class'] === null || $function['method'] === null) { + return $function['context']; + } + + return $lifetimes->getContext($function['class'], $function['method']); + } + + /** + * Find the route action the closure code runs directly in, outside any function nested in it. + * + * @param FunctionFacts $function + * @param list $actions the file's route actions + * + * @return null|RouteAction + */ + private function findAction(array $function, array $actions): ?array + { + $position = $function['position']; + + return array_find($actions, static fn (array $action): bool => $position >= $action['start'] + && $position <= $action['end'] + && ! array_any($action['nested'], static fn (array $range): bool => $position >= $range[0] && $position <= $range[1])); + } + + /** + * Get the key identifying a method or route action, or null for any other function. + * + * @param FunctionFacts $function + * @param list $actions the file's route actions + */ + private function getFunctionKey(array $function, string $file, array $actions): ?string + { + if ($function['closure']) { + $action = $this->findAction($function, $actions); + + return $action === null ? null : $file . '#' . $action['start']; + } + + return $function['class'] === null || $function['method'] === null ? null : strtolower($function['class'] . '::' . $function['method']); + } + + /** + * Determine whether the function gives the parameter another value before the code at its position. + * + * @param FunctionFacts $function + * @param list $actions the file's route actions + * @param array> $reassigned + */ + private function isReassigned(array $function, string $parameter, string $file, array $actions, array $reassigned): bool + { + $key = $this->getFunctionKey($function, $file, $actions); + + return $key !== null && ($reassigned[$key][$parameter] ?? PHP_INT_MAX) < $function['position']; + } + + /** + * Determine whether the container injects the global configuration repository into the function's + * parameter, and the function still holds it: a controller action, a route closure or a job's handler. + * + * @param FunctionFacts $function + * @param list $actions the file's route actions + * @param array> $reassigned + */ + private function isInjectedConfig(array $function, string $parameter, string $file, array $actions, array $reassigned, WorkerLifetimes $lifetimes): bool + { + if ($this->isReassigned($function, $parameter, $file, $actions, $reassigned)) { + return false; + } + + if ($function['closure']) { + $class = $this->findAction($function, $actions)['configParameters'][$parameter] ?? null; + + return $class !== null && $lifetimes->isGlobalConfig($class); + } + + return $function['class'] !== null + && $function['method'] !== null + && $lifetimes->isConfigParameter($function['class'], $function['method'], $parameter); + } + + /** + * Describe when code in the context runs. + */ + private function describeContext(string $context): string + { + return $context === 'job' ? 'while running a job' : 'while handling a request'; + } +} diff --git a/src/phpstan/src/Rules/LegacyPropertyHelper.php b/src/phpstan/src/Rules/LegacyPropertyHelper.php new file mode 100644 index 0000000000..b520f09b5f --- /dev/null +++ b/src/phpstan/src/Rules/LegacyPropertyHelper.php @@ -0,0 +1,425 @@ + $properties + * @param array> $setters the instance methods changing each property + * @return array + */ + public function declarations(ClassReflection $classReflection, ClassLike $class, array $properties, array $setters): array + { + $declarations = []; + + foreach ($properties as $name) { + if (! $classReflection->hasNativeProperty($name)) { + continue; + } + + $property = $classReflection->getNativeReflection()->getProperty($name); + + if ( + $property->getDeclaringClass()->getName() !== $classReflection->getName() + || $property->isStatic() + || ! $property->hasDefaultValue() + || $this->constructorParser->writesProperty($classReflection, $name, $setters[$name] ?? []) + ) { + continue; + } + + $item = $this->propertyItem($class, $name); + $trait = $item === null ? $this->declaringTrait($classReflection, $name) : null; + // An untyped property without a default has a null default but no expression. + $expression = $trait === null ? $item?->default : $property->getBetterReflection()->getDefaultValueExpression(); + + if ($expression === null) { + continue; + } + + $value = $this->constantValue($this->initializerExprTypeResolver->getType( + $expression, + InitializerExprContext::fromClass($classReflection->getName(), $trait?->getFileName() ?? $classReflection->getFileName()), + )); + + if ($value === null) { + continue; + } + + // In the trait, `self` and `__CLASS__` name the trait rather than the class using it. + if ($trait !== null) { + $traitValue = $this->constantValue($this->initializerExprTypeResolver->getType($expression, InitializerExprContext::fromClassReflection($trait))); + + if ($traitValue === null || ! $this->same($traitValue[0], $value[0])) { + continue; + } + } + + $declarations[$name] = [ + 'value' => $value[0], + 'expression' => $item?->default, + 'line' => $item?->getStartLine() ?? $property->getBetterReflection()->getStartLine(), + 'trait' => $trait, + 'public' => $property->isPublic(), + ]; + } + + return $declarations; + } + + /** + * Get the default a property has without the class's own declaration: its parent's, with the class + * declaring it there and whether it is public. Null when that default is not one constant. + * + * @return null|array{value: mixed, declarer: ?string, public: bool} + */ + public function inheritedDefault(ClassReflection $classReflection, string $name): ?array + { + $parent = $classReflection->getParentClass(); + + if ($parent === null || ! $parent->hasNativeProperty($name)) { + return ['value' => null, 'declarer' => null, 'public' => false]; + } + + $property = $parent->getNativeReflection()->getProperty($name); + + if (! $property->hasDefaultValue()) { + return null; + } + + $declarer = $property->getDeclaringClass(); + $expression = $property->getBetterReflection()->getDefaultValueExpression(); + $value = $expression === null ? [null] : $this->constantValue($this->initializerExprTypeResolver->getType( + $expression, + InitializerExprContext::fromClass($declarer->getName(), $declarer->getFileName() ?: null), + )); + + return $value === null ? null : ['value' => $value[0], 'declarer' => $declarer->getName(), 'public' => $property->isPublic()]; + } + + /** + * Get the given attributes on the class and each parent below the base class, nearest first, as the + * runtime reads them: each class's own, then those of the traits it uses directly, in order. + * + * Each attribute holds its constructor arguments by parameter name, a variadic parameter's as a list. + * The attributes whose arguments are not all single constants are listed apart, wherever they are. + * + * @param list $attributeClasses + * @return array{list>, traits: array>>}>, array} + */ + public function attributeLevels(ClassReflection $classReflection, array $attributeClasses, ?string $baseClass): array + { + $levels = []; + $unknown = []; + + foreach ([$classReflection, ...$classReflection->getParents()] as $ancestor) { + if ($ancestor->getName() === $baseClass) { + break; + } + + $level = ['class' => $ancestor->getName(), 'own' => [], 'traits' => []]; + + foreach ([$ancestor, ...$ancestor->getTraits()] as $source) { + $attributes = []; + + foreach ($attributeClasses as $attributeClass) { + $arguments = $this->attributeArguments($source, $attributeClass); + + if ($arguments === false) { + $unknown[$attributeClass] = true; + } elseif ($arguments !== null) { + $attributes[$attributeClass] = $arguments; + } + } + + if ($source === $ancestor) { + $level['own'] = $attributes; + } else { + $level['traits'][$source->getName()] = $attributes; + } + } + + $levels[] = $level; + } + + return [$levels, $unknown]; + } + + /** + * Find the nearest of the levels' attributes of a class: a class's own, then its traits' in order. + * + * @param list>, traits: array>>}> $levels + * @param class-string $attributeClass + * @return null|array{array, string} the attribute's arguments and the class the runtime treats as declaring it + */ + public function nearestAttribute(array $levels, string $attributeClass): ?array + { + foreach ($levels as $level) { + foreach ([$level['own'], ...$level['traits']] as $attributes) { + if (isset($attributes[$attributeClass])) { + return [$attributes[$attributeClass], $level['class']]; + } + } + } + + return null; + } + + /** + * Track the files a class's property defaults, constructors and read attribute arguments come from. + * + * They are not declarations, whose changes alone reanalyze the class's file. + * + * @param list $attributeClasses + * @param DependencyTracker&Scope $scope + */ + public function trackSources(ClassReflection $classReflection, array $attributeClasses, ?string $baseClass, Scope $scope): void + { + $files = SourceFiles::forInstance($this->reflectionProvider, $classReflection, $baseClass); + + foreach ([$classReflection, ...$classReflection->getParents()] as $ancestor) { + if ($ancestor->getName() === $baseClass) { + break; + } + + foreach ([$ancestor, ...$ancestor->getTraits()] as $source) { + foreach ($attributeClasses as $attributeClass) { + $files = [...$files, ...SourceFiles::forAttributeArguments($this->reflectionProvider, $source, $attributeClass)]; + } + } + } + + foreach ($files as $file) { + if ($file !== $scope->getFile()) { + $scope->trackFileDependency($file); + } + } + } + + /** + * Determine whether two values are the same, comparing enum cases by their case. + */ + public function same(mixed $first, mixed $second): bool + { + if ($first instanceof Type || $second instanceof Type) { + return $first instanceof Type && $second instanceof Type && $first->equals($second); + } + + return $first === $second; + } + + /** + * Write a value as PHP code. + */ + public function render(mixed $value): string + { + if ($value instanceof Type) { + $case = $value->getEnumCases()[0]; + + return '\\' . $case->getClassName() . '::' . $case->getEnumCaseName(); + } + + if (is_array($value)) { + $items = []; + $list = array_is_list($value); + + foreach ($value as $key => $item) { + $items[] = ($list ? '' : var_export($key, true) . ' => ') . $this->render($item); + } + + return '[' . implode(', ', $items) . ']'; + } + + return $value === null ? 'null' : var_export($value, true); + } + + /** + * Write a declared default as written, when an attribute on the same class takes its value. + */ + public function printExpression(Expr $expression): string + { + return (new Standard)->prettyPrintExpr($expression); + } + + /** + * Find a property's declaration in the class's source. + * + * Reflection reparses defaults without their positions, and one declaration can hold several properties. + */ + private function propertyItem(ClassLike $class, string $name): ?PropertyItem + { + foreach ($class->getProperty($name)->props ?? [] as $item) { + if ($item->name->toString() === $name) { + return $item; + } + } + + return null; + } + + /** + * Find the trait the class uses directly that declares the property itself, when exactly one does. + */ + private function declaringTrait(ClassReflection $classReflection, string $name): ?ClassReflection + { + $traits = array_filter($classReflection->getTraits(), static fn (ClassReflection $trait): bool => $trait->hasNativeProperty($name)); + + if (count($traits) !== 1) { + return null; + } + + $trait = reset($traits); + + foreach ($trait->getTraits(true) as $nested) { + if ($nested->hasNativeProperty($name)) { + return null; + } + } + + return $trait; + } + + /** + * Get an attribute's constructor arguments by parameter name: null when the source does not declare the + * attribute, and false when an argument is not one constant. + * + * @param class-string $attributeClass + * @return null|array|false + */ + private function attributeArguments(ClassReflection $source, string $attributeClass): array|false|null + { + $attribute = $source->getNativeReflection()->getAttributes($attributeClass)[0] ?? null; + + if ($attribute === null) { + return null; + } + + $parameters = $this->reflectionProvider->getClass($attributeClass)->getNativeReflection()->getConstructor()?->getParameters() ?? []; + $last = $parameters === [] ? null : $parameters[array_key_last($parameters)]; + $context = InitializerExprContext::fromClassReflection($source); + $arguments = []; + + foreach ($attribute->getArgumentsExpressions() as $key => $expression) { + $value = $this->constantValue($this->initializerExprTypeResolver->getType($expression, $context)); + $parameter = null; + + foreach ($parameters as $index => $candidate) { + if ($key === $index || $key === $candidate->getName()) { + $parameter = $candidate; + + break; + } + } + + // The variadic parameter collects the remaining positional arguments and unmatched named ones. + $parameter ??= $last?->isVariadic() ? $last : null; + + if ($value === null || $parameter === null || ($parameter->isVariadic() && $value[0] instanceof Type)) { + return false; + } + + if ($parameter->isVariadic()) { + $arguments[$parameter->getName()][] = $value[0]; + } else { + $arguments[$parameter->getName()] = $value[0]; + } + } + + return $arguments; + } + + /** + * Get the one constant value a type holds: a scalar, null, an array of those, or an enum case. + * + * @return null|array{mixed} the value, or null when the type is not one such constant + */ + private function constantValue(Type $type): ?array + { + $scalars = $type->getConstantScalarValues(); + + if (count($scalars) === 1 && $type->isConstantScalarValue()->yes()) { + return [$scalars[0]]; + } + + $enumCases = $type->getEnumCases(); + + if (count($enumCases) === 1 && $type->isObject()->yes()) { + return [$enumCases[0]]; + } + + $arrays = $type->getConstantArrays(); + + if (count($arrays) !== 1 || ! $type->isConstantArray()->yes() || $arrays[0]->getOptionalKeys() !== []) { + return null; + } + + $array = []; + + foreach ($arrays[0]->getKeyTypes() as $index => $keyType) { + $item = $this->constantValue($arrays[0]->getValueTypes()[$index]); + + if ($item === null || (! is_scalar($item[0]) && $item[0] !== null)) { + return null; + } + + $array[$keyType->getValue()] = $item[0]; + } + + return [$array]; + } +} diff --git a/src/phpstan/src/Rules/ModelAppendsRule.php b/src/phpstan/src/Rules/ModelAppendsRule.php new file mode 100644 index 0000000000..df8ccebb98 --- /dev/null +++ b/src/phpstan/src/Rules/ModelAppendsRule.php @@ -0,0 +1,261 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkModelAppends%')] +class ModelAppendsRule implements Rule +{ + /** + * The model methods that decide how serialization reads appended properties. + */ + private const array SERIALIZATION_METHODS = [ + 'toArray', + 'attributesToArray', + 'getArrayableAppends', + 'mutateAttributeForArray', + 'mutateAttribute', + ]; + + /** + * Create a new model appends rule instance. + */ + public function __construct( + private ModelHelper $modelHelper, + private ModelRuleHelper $modelRuleHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * Report appended properties that serialization cannot compute. + * + * @param DependencyTracker&Scope $scope + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if (! $classReflection->is(Model::class) + || $classReflection->isAbstract() + || ! $this->modelRuleHelper->usesModelMethods($classReflection, self::SERIALIZATION_METHODS)) { + return []; + } + + // A model the runtime cannot instantiate has no known casts. + try { + $this->modelHelper->getInstance($classReflection); + } catch (ReflectionException) { + return []; + } + + $this->modelRuleHelper->trackModelSources($classReflection, $scope); + + $errors = []; + + foreach ($this->appendedProperties($classReflection, $node, $scope) as [$name, $line, $declaration]) { + if ($this->modelRuleHelper->isComputedProperty($classReflection, $name)) { + continue; + } + + $errors[] = RuleErrorBuilder::message($classReflection->hasInstanceProperty($name) + ? sprintf("Property '%s' is not a computed property, remove from %s.", $name, $declaration) + : sprintf("Property '%s' does not exist in model.", $name)) + ->identifier('hypervel.modelAppends') + ->line($line) + ->build(); + } + + return $errors; + } + + /** + * Get each property the model appends, with the line to report it at and the declaration naming it. + * + * Like HasAttributes::initializeHasAttributes(), the #[Appends] properties are added to those of $appends. + * A parent's or trait's appends are reported at the model, which can define what they need. Reflection + * reparses expressions without their positions, so the model's own declarations are read from its source. + * + * @param DependencyTracker&Scope $scope + * @return list + */ + private function appendedProperties(ClassReflection $classReflection, InClassNode $node, Scope $scope): array + { + $class = $node->getOriginalNode(); + $properties = []; + $property = $classReflection->getNativeReflection()->getProperty('appends'); + + if ($property->hasDefaultValue()) { + $declaringClass = $property->getDeclaringClass(); + $declared = $declaringClass->getName() === $classReflection->getName(); + $context = InitializerExprContext::fromClass($declaringClass->getName(), $declaringClass->getFileName() ?: null); + $default = $declared ? $this->modelRuleHelper->declaredPropertyDefault($class, 'appends') : $property->getDefaultValueExpression(); + + foreach ($this->listedNames($default === null ? [] : [$default], $context) as [$name, $expression]) { + $properties[] = [$name, $declared ? $expression->getStartLine() : $node->getStartLine(), '$appends']; + } + } + + $attribute = ReflectionHelper::findClassAttribute($classReflection, Appends::class); + + if ($attribute !== null) { + $source = $attribute[2]; + + foreach (SourceFiles::forAttributeArguments($this->reflectionProvider, $source, Appends::class) as $file) { + if ($file !== $scope->getFile()) { + $scope->trackFileDependency($file); + } + } + + $declared = $source->getName() === $classReflection->getName(); + $arguments = $declared + ? array_map(static fn (Arg $argument): Expr => $argument->value, $this->findAttributeNode($class)->args ?? []) + : array_values($source->getNativeReflection()->getAttributes(Appends::class)[0]->getArgumentsExpressions()); + + foreach ($this->listedNames($arguments, InitializerExprContext::fromClassReflection($source)) as [$name, $expression]) { + $properties[] = [$name, $declared ? $expression->getStartLine() : $node->getStartLine(), '#[Appends]']; + } + } + + return $properties; + } + + /** + * Find the #[Appends] attribute in the class's source. + */ + private function findAttributeNode(ClassLike $class): ?Attribute + { + foreach ($class->attrGroups as $attributeGroup) { + foreach ($attributeGroup->attrs as $attribute) { + if (strcasecmp($attribute->name->toString(), Appends::class) === 0) { + return $attribute; + } + } + } + + return null; + } + + /** + * Get the constant names a $appends default or the #[Appends] arguments list, each with the expression naming it. + * + * Like the Appends constructor, a first array argument holds the names, and otherwise each argument is one. + * + * @param list $expressions + * @return list + */ + private function listedNames(array $expressions, InitializerExprContext $context): array + { + $first = $expressions[0] ?? null; + + if ($first instanceof Array_) { + $names = []; + + // An unpacked item lists the names of the array it spreads. + foreach ($first->items as $item) { + $names = [...$names, ...($item->unpack ? $this->arrayNames($item->value, $context) : $this->constantNames($item->value, $context))]; + } + + return $names; + } + + if ($first !== null && $this->initializerExprTypeResolver->getType($first, $context)->isArray()->yes()) { + return $this->arrayNames($first, $context); + } + + $names = []; + + foreach ($expressions as $expression) { + $names = [...$names, ...$this->constantNames($expression, $context)]; + } + + return $names; + } + + /** + * Get the constant names an array expression holds, each with that expression. + * + * @return list + */ + private function arrayNames(Expr $expression, InitializerExprContext $context): array + { + $names = []; + + foreach ($this->initializerExprTypeResolver->getType($expression, $context)->getConstantArrays() as $array) { + foreach ($array->getValueTypes() as $valueType) { + foreach ($valueType->getConstantStrings() as $name) { + $names[] = [$name->getValue(), $expression]; + } + } + } + + return $names; + } + + /** + * Get the constant names an expression can be, each with that expression. + * + * @return list + */ + private function constantNames(Expr $expression, InitializerExprContext $context): array + { + $names = []; + + foreach ($this->initializerExprTypeResolver->getType($expression, $context)->getConstantStrings() as $name) { + $names[] = [$name->getValue(), $expression]; + } + + return $names; + } +} diff --git a/src/phpstan/src/Rules/ModelPropertyColumnRule.php b/src/phpstan/src/Rules/ModelPropertyColumnRule.php new file mode 100644 index 0000000000..f6b7547adc --- /dev/null +++ b/src/phpstan/src/Rules/ModelPropertyColumnRule.php @@ -0,0 +1,263 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkModelProperties%')] +class ModelPropertyColumnRule implements Rule +{ + /** + * The column parameter of each checked method, and the classes whose implementation of it is checked. + * + * @var array}> + */ + private const array METHODS = [ + 'paginate' => ['columns', 1, [EloquentBuilder::class, BelongsToMany::class, HasOneOrManyThrough::class]], + 'simplepaginate' => ['columns', 1, [EloquentBuilder::class, BelongsToMany::class, HasOneOrManyThrough::class]], + 'cursorpaginate' => ['columns', 1, [EloquentBuilder::class, BelongsToMany::class, HasOneOrManyThrough::class]], + 'pluck' => ['column', 0, [EloquentBuilder::class]], + ]; + + /** + * Create the rule. + */ + public function __construct( + private BuilderHelper $builderHelper, + private ModelPropertyHelper $modelPropertyHelper, + private ModelRuleHelper $modelRuleHelper, + private RuleLevelHelper $ruleLevelHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report a column that is not a property of the queried model. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + // PHPStan also analyzes a nullsafe call as a method call on the non-null object, so it is checked there once. + if (! ($node instanceof MethodCall || $node instanceof StaticCall) || ! $node->name instanceof Identifier) { + return []; + } + + // Query\Builder::__call() matches the prefix case-sensitively. + if (str_starts_with($node->name->toString(), 'where')) { + return $this->checkDynamicWhere($node, $scope, $node->name->toString()); + } + + $methodName = $node->name->toLowerString(); + + if (! isset(self::METHODS[$methodName])) { + return []; + } + + [$parameterName, $position, $implementations] = self::METHODS[$methodName]; + $argument = $node->getArg($parameterName, $position); + + if ($argument === null || $argument->unpack) { + return []; + } + + $callerType = $this->modelRuleHelper->callerType($node, $scope); + $callerClasses = $callerType->getObjectClassReflections(); + + if (count($callerClasses) !== 1) { + return []; + } + + [$modelType, $implementation] = $this->resolveCall($callerClasses[0], $callerType, $node->name->toString()); + + if ($modelType === null || ! in_array($implementation, $implementations, strict: true)) { + return []; + } + + $property = new GenericModelPropertyType($modelType, $this->modelPropertyHelper); + $columns = new ArrayType(new IntegerType, TypeCombinator::union($property, new ObjectType(Expression::class), new ConstantStringType('*'))); + + // These are the types the upstream stubs declare. The relations take only a list natively. + $expectedType = match (true) { + $methodName === 'pluck' => TypeCombinator::union(new ObjectType(Expression::class), $property), + $implementation === EloquentBuilder::class => TypeCombinator::union($columns, $property, new ConstantStringType('*')), + default => $columns, + }; + $argumentType = $scope->getType($argument->value); + $accepts = $this->ruleLevelHelper->accepts($expectedType, $argumentType, $scope->isDeclareStrictTypes()); + + if ($accepts->result) { + return []; + } + + $verbosity = VerbosityLevel::getRecommendedLevelByType($expectedType, $argumentType); + + return [ + RuleErrorBuilder::message(sprintf( + 'Parameter #%d $%s of %s %s::%s() expects %s, %s given.', + $position + 1, + $parameterName, + $node instanceof StaticCall ? 'static method' : 'method', + $callerType->describe(VerbosityLevel::typeOnly()), + $node->name->toString(), + $expectedType->describe($verbosity), + $argumentType->describe($verbosity), + ))->identifier('argument.type')->acceptsReasonsTip($accepts->reasons)->build(), + ]; + } + + /** + * Report a dynamic where, such as whereEmail(), naming a column that is not a property of the queried model. + * + * The forwarding extensions resolve every dynamic where, as Query\Builder::__call() runs it, so the method's + * existence does not depend on the model. + * + * @return list + */ + private function checkDynamicWhere(MethodCall|StaticCall $node, Scope $scope, string $methodName): array + { + $callerType = $this->modelRuleHelper->callerType($node, $scope); + $callerClasses = $callerType->getObjectClassReflections(); + + // A native method, such as the builder's where(), is not a dynamic where. + if (count($callerClasses) !== 1 || $callerClasses[0]->hasNativeMethod($methodName)) { + return []; + } + + $modelType = $this->modelRuleHelper->queriedModel($callerClasses[0], $callerType); + + // An unknown model may have any column. + if ($modelType === null || $modelType->getObjectClassReflections() === []) { + return []; + } + + $method = $scope->getMethodReflection($callerType, $methodName)?->getPrototype(); + + // Each forwarding hop, such as a relation's to its builder, wraps the synthesized method. + while ($method instanceof ForwardedMethodReflection) { + $method = $method->getForwardedMethod()->getPrototype(); + } + + if (! $method instanceof EloquentBuilderMethodReflection) { + return []; + } + + // Split like Query\Builder::dynamicWhere(); each segment names a column. + $segments = preg_split('/(And|Or)(?=[A-Z])/', substr($methodName, 5), -1, PREG_SPLIT_DELIM_CAPTURE); + + foreach ($segments === false ? [] : $segments as $segment) { + $column = Str::snake($segment); + + if ($segment === 'And' || $segment === 'Or' || $this->modelPropertyHelper->acceptsPropertyName($modelType, $column)->yes()) { + continue; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Call to an undefined %s %s::%s().', + $node instanceof StaticCall ? 'static method' : 'method', + $callerType->describe(VerbosityLevel::typeOnly()), + $methodName, + )) + ->identifier($node instanceof StaticCall ? 'staticMethod.notFound' : 'method.notFound') + ->tip(sprintf('Column %s is not a property of %s.', $column, $modelType->describe(VerbosityLevel::typeOnly()))) + ->build(), + ]; + } + + return []; + } + + /** + * Resolve the queried model and the class whose method runs, following Relation::__call() and + * Model::__call() to the model's builder. + * + * @return array{null|Type, null|string} + */ + private function resolveCall(ClassReflection $callerClass, Type $callerType, string $methodName): array + { + $modelType = $this->modelRuleHelper->queriedModel($callerClass, $callerType); + + // A model's own method of that name is not forwarded. + if ($modelType === null || ($callerClass->is(Model::class) && $callerClass->hasNativeMethod($methodName))) { + return [null, null]; + } + + return [$modelType, $callerClass->hasNativeMethod($methodName) + ? $callerClass->getNativeMethod($methodName)->getDeclaringClass()->getName() + : $this->builderImplementation($modelType, $methodName)]; + } + + /** + * Return the class whose method the model's builder runs. + */ + private function builderImplementation(Type $modelType, string $methodName): ?string + { + $modelClasses = $modelType->getObjectClassNames(); + $builderClasses = count($modelClasses) === 1 + ? $this->builderHelper->determineBuilderClass($modelClasses[0], $modelType)?->getObjectClassReflections() ?? [] + : []; + + return count($builderClasses) === 1 + ? $builderClasses[0]->getNativeMethod($methodName)->getDeclaringClass()->getName() + : null; + } +} diff --git a/src/phpstan/src/Rules/ModelRelationDefaultsRule.php b/src/phpstan/src/Rules/ModelRelationDefaultsRule.php new file mode 100644 index 0000000000..527a300807 --- /dev/null +++ b/src/phpstan/src/Rules/ModelRelationDefaultsRule.php @@ -0,0 +1,93 @@ + + */ +#[AutowiredService] +class ModelRelationDefaultsRule implements Rule +{ + /** + * Create a new model relation defaults rule instance. + */ + public function __construct( + private RelationExistenceHelper $relationExistenceHelper, + private ModelRuleHelper $modelRuleHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * Report each default eager load whose relation the model does not define. + * + * @param DependencyTracker&Scope $scope + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $class = $node->getClassReflection(); + + if (! $class->is(Model::class) || $class->isAbstract()) { + return []; + } + + $this->modelRuleHelper->trackModelSources($class, $scope); + + $errors = []; + + foreach (['with', 'withCount'] as $name) { + $property = $class->getNativeReflection()->getProperty($name); + + if (! $property->hasDefaultValue()) { + continue; + } + + $declared = $property->getDeclaringClass()->getName() === $class->getName(); + $default = $declared + ? $this->modelRuleHelper->declaredPropertyDefault($node->getOriginalNode(), $name) + : $property->getDefaultValueExpression(); + + if ($default === null) { + continue; + } + + $type = $this->initializerExprTypeResolver->getType($default, InitializerExprContext::fromClass( + $property->getDeclaringClass()->getName(), + $property->getDeclaringClass()->getFileName() ?: null, + )); + + // Inherited defaults belong to the concrete child, which may supply the relation. + $location = $declared ? $default : $node; + $errors = array_merge($errors, $this->relationExistenceHelper->check($type, new ObjectType($class->getName()), $location, $scope, $name === 'withCount')); + } + + return $errors; + } +} diff --git a/src/phpstan/src/Rules/ModelRuleHelper.php b/src/phpstan/src/Rules/ModelRuleHelper.php new file mode 100644 index 0000000000..2c388a9eec --- /dev/null +++ b/src/phpstan/src/Rules/ModelRuleHelper.php @@ -0,0 +1,337 @@ + + */ + private array $plainQueries = []; + + /** + * Create a new model rule helper instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private BuilderHelper $builderHelper, + private ModelPropertyHelper $modelPropertyHelper, + private ModelCastHelper $modelCastHelper, + ) { + } + + /** + * Return the type the method is called on, with a class-string receiver as its object type. + */ + public function callerType(MethodCall|StaticCall $node, Scope $scope): Type + { + return match (true) { + $node instanceof MethodCall => $scope->getType($node->var), + $node->class instanceof Name => $scope->resolveTypeByName($node->class), + default => $scope->getType($node->class)->getObjectTypeOrClassStringObjectType(), + }; + } + + /** + * Get the exact classes a static call runs on, or null when a subclass could be called instead. + * + * @return null|list + */ + public function exactCalledClasses(StaticCall $call, Scope $scope): ?array + { + $class = $call->class; + + if ($class instanceof Name) { + // self, static and parent forward late static binding, so only a final calling class is exact. + if ($class->isSpecialClassName()) { + $classReflection = $scope->getClassReflection(); + + return $classReflection?->isFinal() ? [$classReflection] : null; + } + + return $this->reflectionProvider->hasClass($class->toString()) + ? [$this->reflectionProvider->getClass($class->toString())] + : null; + } + + $type = $scope->getType($class); + + if ($type->isConstantValue()->yes()) { + $classes = []; + + foreach ($type->getConstantStrings() as $constantString) { + if (! $this->reflectionProvider->hasClass($constantString->getValue())) { + return null; + } + + $classes[] = $this->reflectionProvider->getClass($constantString->getValue()); + } + + return $classes === [] ? null : $classes; + } + + $type = $type->getObjectTypeOrClassStringObjectType(); + $classes = $type->getObjectClassReflections(); + + return $type->isObject()->yes() && $classes !== [] && array_all($classes, static fn (ClassReflection $class): bool => $class->isFinal()) + ? $classes + : null; + } + + /** + * Return the model a builder, relation or model queries. + */ + public function queriedModel(ClassReflection $callerClass, Type $callerType): ?Type + { + return match (true) { + $callerClass->is(Relation::class) => $callerType->getTemplateType(Relation::class, 'TRelatedModel'), + $callerClass->is(EloquentBuilder::class) => $callerType->getTemplateType(EloquentBuilder::class, 'TModel'), + $callerClass->is(Model::class) => $callerType, + default => null, + }; + } + + /** + * Determine whether a concrete model builds its queries as the base Model does: through Model's query + * methods and the base builder, without any global scope. + * + * Global scopes may add any clause, and boot code can register them, so a model with boot or initialize + * hooks below Model (found as bootTraits() finds them), #[ScopedBy], a scope in the booted registry or a + * boot listener is not plain. + */ + public function buildsPlainQueries(ClassReflection $modelClass): bool + { + return $this->plainQueries[$modelClass->getName()] ??= $this->resolvePlainQueries($modelClass); + } + + /** + * Resolve whether a concrete model builds plain queries. + */ + private function resolvePlainQueries(ClassReflection $modelClass): bool + { + if ($modelClass->isAbstract() || ! $this->usesModelMethods($modelClass, self::QUERY_METHODS)) { + return false; + } + + $bootMethods = []; + $initializeMethods = []; + + foreach (array_keys($modelClass->getTraits(true)) as $trait) { + $bootMethods['boot' . class_basename($trait)] = true; + $initializeMethods['initialize' . class_basename($trait)] = true; + } + + foreach ($modelClass->getNativeReflection()->getMethods() as $method) { + if ($method->getDeclaringClass()->getName() === Model::class) { + continue; + } + + if (in_array(strtolower($method->getName()), ['booting', 'boot', 'booted'], true) + || ($method->isStatic() && (isset($bootMethods[$method->getName()]) || $method->getAttributes(Boot::class) !== [])) + || isset($initializeMethods[$method->getName()]) + || $method->getAttributes(Initialize::class) !== []) { + return false; + } + } + + $builder = $this->builderHelper->determineBuilderClass($modelClass->getName(), new ObjectType($modelClass->getName())); + + if ($builder?->getObjectClassNames() !== [EloquentBuilder::class] + || (Model::getAllGlobalScopes()[$modelClass->getName()] ?? []) !== []) { + return false; + } + + // Like HasGlobalScopes::resolveGlobalScopeAttributes(), a subclass of ScopedBy declares scopes too. + for ($class = $modelClass; $class !== null; $class = $class->getParentClass()) { + foreach ([$class, ...array_values($class->getTraits())] as $source) { + if ($source->getNativeReflection()->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF) !== []) { + return false; + } + } + } + + $dispatcher = Model::getEventDispatcher(); + + return $dispatcher === null + || (! $dispatcher->hasListeners('eloquent.booting: ' . $modelClass->getName()) + && ! $dispatcher->hasListeners('eloquent.booted: ' . $modelClass->getName())); + } + + /** + * Determine whether the model runs Model's own implementation of each method, which a subclass or trait may + * override. + * + * @param list $methods + */ + public function usesModelMethods(ClassReflection $modelClass, array $methods): bool + { + return array_all( + $methods, + static fn (string $method): bool => $modelClass->getNativeMethod($method)->getDeclaringClass()->getName() === Model::class, + ); + } + + /** + * Determine whether reading the property runs a get accessor or a class cast, which can compute it from other + * attributes, as HasAttributes::transformModelValue() and mutateAttributeForArray() read it. + */ + public function isComputedProperty(ClassReflection $modelClass, string $name): bool + { + if ($this->modelPropertyHelper->getGetMutatorType($modelClass, $name) !== null + || $this->modelPropertyHelper->getAttributeGetMutatorType($modelClass, $name) !== null) { + return true; + } + + if (! $this->modelCastHelper->hasCastForProperty($modelClass, $name)) { + return false; + } + + $cast = $this->modelCastHelper->getCastForProperty($modelClass, $name); + + // A cast that cannot be determined may be a class cast. + if ($cast === null) { + return true; + } + + $castClass = $this->modelCastHelper->getCastClass($cast); + + // An enum cast reads the value alone. + return $castClass !== null && ! $castClass->isEnum(); + } + + /** + * Get the default a class declares for one of its own properties, from its source. + * + * Reflection reparses defaults without their positions, and one declaration can hold several properties. + */ + public function declaredPropertyDefault(ClassLike $class, string $name): ?Expr + { + foreach ($class->getProperty($name)->props ?? [] as $property) { + if ($property->name->toString() === $name) { + return $property->default; + } + } + + return null; + } + + /** + * Determine whether a parent class or interface declares the method, whose name and visibility the model keeps. + */ + public function declaresInheritedMethod(ClassReflection $modelClass, string $methodName): bool + { + foreach ([$modelClass->getParentClass(), ...$modelClass->getInterfaces()] as $ancestor) { + if ($ancestor?->hasNativeMethod($methodName)) { + return true; + } + } + + return false; + } + + /** + * Determine whether a model method is a legacy scope: Model::hasNamedScope() finds it by its name, and its first + * parameter takes the builder, unlike a helper whose name only starts with "scope", such as scopeExistsIn(). + */ + public function isLegacyScopeMethod(ExtendedMethodReflection $method): bool + { + $parameter = $method->getVariants()[0]->getParameters()[0] ?? null; + + return ModelScopeMethodResolver::isLegacyScopeName($method->getName()) + && $parameter !== null + && ! $parameter->getNativeType()->isSuperTypeOf(new ObjectType(EloquentBuilder::class))->no(); + } + + /** + * Return the name of the attributed scope that can replace a legacy scope method. + * + * A model method already using that name keeps the legacy scope, which is then the only way to keep the scope's + * name, as DatabaseNotification's scopeRead() sits beside read(). + */ + public function attributedScopeName(ClassReflection $modelClass, ExtendedMethodReflection $method): ?string + { + if (! $this->isLegacyScopeMethod($method)) { + return null; + } + + $scopeName = lcfirst(substr($method->getName(), 5)); + + return $modelClass->hasNativeMethod($scopeName) ? null : $scopeName; + } + + /** + * Track the files a model's property defaults and method bodies come from, for a rule checking the model's class. + * + * They are not declarations, whose changes alone reanalyze the model's file. + * + * @param DependencyTracker&Scope $scope + */ + public function trackModelSources(ClassReflection $modelClass, Scope $scope): void + { + foreach (SourceFiles::forInstance($this->reflectionProvider, $modelClass, Model::class) as $file) { + if ($file !== $scope->getFile()) { + $scope->trackFileDependency($file); + } + } + } +} diff --git a/src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php b/src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php new file mode 100644 index 0000000000..33dab002ff --- /dev/null +++ b/src/phpstan/src/Rules/NoAuthFacadeInRequestScopeRule.php @@ -0,0 +1,103 @@ + */ +#[Conditional(attribute: new AutowiredService, if: '%checkAuthCallsWhenInRequestScope%')] +class NoAuthFacadeInRequestScopeRule implements Rule +{ + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Report Auth facade user checks made where the request is available. + * + * @param StaticCall $node + * + * @return list + * + * @throws ShouldNotHappenException + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->class instanceof Name) { + return []; + } + + $className = $node->class->toString(); + + if ($className !== Auth::class && ! (new ObjectType(Auth::class))->isSuperTypeOf(new ObjectType($className))->yes()) { + return []; + } + + if (! $node->name instanceof Node\Identifier) { + return []; + } + + $methodName = $node->name->toLowerString(); + + if (! in_array($methodName, ['check', 'user', 'guest'], true)) { + return []; + } + + $message = match ($methodName) { + 'check' => 'Do not use Auth::check() in a class that has access to the request. Use $%s->user() !== null instead.', + 'user' => 'Do not use Auth::user() in a class that has access to the request. Use $%s->user() instead.', + 'guest' => 'Do not use Auth::guest() in a class that has access to the request. Use $%s->user() === null instead.', + }; + + // Upstream's automatic fix is omitted: a custom user resolver can make the request return another + // user. Upstream also suggests $this->user() in static contexts, which have no $this. + if ( + $scope->isInClass() + && $scope->getClassReflection()->is(Request::class) + && $scope->hasVariableType('this')->yes() + ) { + return [ + RuleErrorBuilder::message(sprintf($message, 'this')) + ->identifier('hypervel.noAuthFacadeInRequestScope') + ->build(), + ]; + } + + if (! $scope->hasVariableType('request')->yes()) { + return []; + } + + $requestType = $scope->getVariableType('request'); + + if (! (new ObjectType(Request::class))->isSuperTypeOf($requestType)->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf($message, 'request')) + ->identifier('hypervel.noAuthFacadeInRequestScope') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php b/src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php new file mode 100644 index 0000000000..f04a6eb043 --- /dev/null +++ b/src/phpstan/src/Rules/NoAuthHelperInRequestScopeRule.php @@ -0,0 +1,120 @@ + */ +#[Conditional(attribute: new AutowiredService, if: '%checkAuthCallsWhenInRequestScope%')] +class NoAuthHelperInRequestScopeRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Report auth() helper user checks made where the request is available. + * + * @param MethodCall $node + * + * @return list + * + * @throws ShouldNotHappenException + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Node\Identifier) { + return []; + } + + $methodName = $node->name->toLowerString(); + + if (! in_array($methodName, ['check', 'user', 'guest'], true)) { + return []; + } + + if (! $node->var instanceof Node\Expr\FuncCall) { + return []; + } + + // Upstream compares the written name, which matches a namespaced auth() function and misses a miscased call. + if (! $node->var->name instanceof Name + || strtolower((string) $this->reflectionProvider->resolveFunctionName($node->var->name, $scope)) !== 'auth' + ) { + return []; + } + + $calledOnType = $scope->getType($node->var); + + if (! (new ObjectType(AuthManager::class))->isSuperTypeOf($calledOnType)->yes()) { + return []; + } + + $message = match ($methodName) { + 'check' => 'Do not use auth()->check() in a class that has access to the request. Use $%s->user() !== null instead.', + 'user' => 'Do not use auth()->user() in a class that has access to the request. Use $%s->user() instead.', + 'guest' => 'Do not use auth()->guest() in a class that has access to the request. Use $%s->user() === null instead.', + }; + + // Upstream's automatic fix is omitted: a custom user resolver can make the request return another + // user. Upstream also suggests $this->user() in static contexts, which have no $this. + if ( + $scope->isInClass() + && $scope->getClassReflection()->is(Request::class) + && $scope->hasVariableType('this')->yes() + ) { + return [ + RuleErrorBuilder::message(sprintf($message, 'this')) + ->identifier('hypervel.noAuthHelperInRequestScope') + ->build(), + ]; + } + + if (! $scope->hasVariableType('request')->yes()) { + return []; + } + + $requestType = $scope->getVariableType('request'); + + if (! (new ObjectType(Request::class))->isSuperTypeOf($requestType)->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf($message, 'request')) + ->identifier('hypervel.noAuthHelperInRequestScope') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php b/src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php new file mode 100644 index 0000000000..1d3b3facc1 --- /dev/null +++ b/src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php @@ -0,0 +1,191 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noEnvCallsOutsideOfConfig%')] +class NoEnvCallsOutsideOfConfigRule implements Rule +{ + /** + * The global helpers that read the environment, including app_id(), which supplies config defaults. + */ + private const array FUNCTIONS = ['env', 'env_array', 'env_or_fail', 'app_id']; + + /** + * The Env methods that read a variable, lowercased. + */ + private const array METHODS = ['get', 'getarray', 'getorfail']; + + /** + * The single reflection files are PHPStan's normalized `--tmp-file` and + * `--instead-of` paths, which are set in editor mode. + */ + public function __construct( + private ConfigSourceResolver $configSourceResolver, + private FileHelper $fileHelper, + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + #[AutowiredParameter] + private ?string $singleReflectionFile = null, + #[AutowiredParameter] + private ?string $singleReflectionInsteadOfFile = null, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report environment reads outside of config files. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $reader = $this->getReaderName($node, $scope); + + if ($reader === null) { + return []; + } + + // The booted application decides the config directories and package config files. + $this->runtimeInputs->track($scope); + + if (! $this->isCalledOutsideOfConfig($node, $scope)) { + return []; + } + + // Upstream says env() returns null once the config is cached; it still reads the + // process environment, but the .env file is no longer loaded. + return [ + RuleErrorBuilder::message(sprintf("Called '%s' outside of the config directory. The .env file is not loaded when the configuration is cached, so use 'config' instead.", $reader)) + ->identifier('hypervel.noEnvCallsOutsideOfConfig') + ->line($node->getStartLine()) + ->file($scope->getFile(), $scope->getFileDescription()) + ->build(), + ]; + } + + /** + * Get the name of the env helper or Env reader the call makes, or null when it makes neither. + */ + private function getReaderName(CallLike $call, Scope $scope): ?string + { + if ($call instanceof FuncCall && $call->name instanceof Name) { + $function = strtolower($this->reflectionProvider->resolveFunctionName($call->name, $scope) ?? ''); + + return in_array($function, self::FUNCTIONS, true) ? $function : null; + } + + if (! $call instanceof StaticCall + || ! $call->class instanceof Name + || ! $call->name instanceof Identifier + || ! in_array($call->name->toLowerString(), self::METHODS, true)) { + return null; + } + + $className = $scope->resolveName($call->class); + + if (! $this->reflectionProvider->hasClass($className)) { + return null; + } + + $class = $this->reflectionProvider->getClass($className); + + if (! $class->is(Env::class)) { + return null; + } + + return $class->getName() . '::' . $class->getNativeMethod($call->name->toString())->getName(); + } + + /** + * Determine whether the call is outside of every config directory and package config file. + */ + protected function isCalledOutsideOfConfig(CallLike $call, Scope $scope): bool + { + $file = $this->fileHelper->normalizePath($this->resolveAnalysedFile($scope->getFile())); + + $configDirectories = [ + ...$this->configSourceResolver->getConfigDirectories(), + $this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath()), + $this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigStubPath()), + ]; + + foreach ($configDirectories as $configDirectory) { + // Upstream's bare prefix match lets /app/config permit /app/config-sibling. + if (str_starts_with($file, $configDirectory . DIRECTORY_SEPARATOR)) { + return false; + } + } + + return ! $this->configSourceResolver->isPackageConfigFile($file); + } + + /** + * In editor mode PHPStan analyses the temporary file, so map it back to + * the file it stands in for. + */ + private function resolveAnalysedFile(string $scopeFile): string + { + if ($this->singleReflectionFile === null) { + return $scopeFile; + } + + if ($this->fileHelper->normalizePath($scopeFile) !== $this->singleReflectionFile) { + return $scopeFile; + } + + return $this->singleReflectionInsteadOfFile ?? $scopeFile; + } +} diff --git a/src/phpstan/src/Rules/NoImplicitQueryBuilderCallRule.php b/src/phpstan/src/Rules/NoImplicitQueryBuilderCallRule.php new file mode 100644 index 0000000000..7ad7da4127 --- /dev/null +++ b/src/phpstan/src/Rules/NoImplicitQueryBuilderCallRule.php @@ -0,0 +1,168 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noImplicitQueryBuilderCall%')] +class NoImplicitQueryBuilderCallRule implements MultipleNodeTypesRule +{ + /** + * The model methods that forward an instance call to newQuery(). + */ + private const array FORWARDING_METHODS = ['__call', 'forwardCallTo']; + + /** + * The model methods that forward a static call to a new instance's newQuery(), which query() also runs. + */ + private const array STATIC_FORWARDING_METHODS = ['__callStatic', '__call', 'forwardCallTo', 'query']; + + /** + * Create a new implicit query builder call rule instance. + */ + public function __construct( + private ForwardedModelMethodExtension $forwardedModelMethodExtension, + private NamedScopeMethodExtension $namedScopeMethodExtension, + private ModelRuleHelper $modelRuleHelper, + ) { + } + + /** + * Get the node types the rule checks. + */ + public function getNodeTypes(): array + { + return [StaticCall::class, MethodCall::class, NullsafeMethodCall::class]; + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report a model call that is forwarded to the query builder. + * + * @return list + */ + public function processNode(Node $node, AnalyserScope $scope): array + { + if ((! $node instanceof StaticCall && ! $node instanceof MethodCall && ! $node instanceof NullsafeMethodCall) || ! $node->name instanceof Identifier) { + return []; + } + + if ($node->getAttribute('virtualNullsafeMethodCall', false)) { + return []; + } + + $receiver = $node instanceof StaticCall ? $node->class : $node->var; + $type = $receiver instanceof Name ? $scope->resolveTypeByName($receiver) : $scope->getType($receiver); + + if ($node instanceof StaticCall) { + $type = $type->getObjectTypeOrClassStringObjectType(); + } elseif ($node instanceof NullsafeMethodCall) { + $type = TypeCombinator::removeNull($type); + } + + if (! (new ObjectType(Model::class))->isSuperTypeOf($type)->yes()) { + return []; + } + + $reflections = $type->getObjectClassReflections(); + + if (count($reflections) !== 1) { + return []; + } + + $model = $reflections[0]; + $methodName = $node->name->toString(); + + if ($model->hasNativeMethod($methodName)) { + $method = $model->getNativeMethod($methodName); + + if ($scope->canCallMethod($method)) { + return []; + } + + $isScope = false; + + foreach ($method->getAttributes() as $attribute) { + if ($attribute->getName() === Scope::class) { + $isScope = true; + break; + } + } + + if (! $isScope) { + return []; + } + } + + // Unlike upstream's single extension, named scopes are described apart from the other forwarded builder methods. + if (! $this->forwardedModelMethodExtension->hasMethod($model, $methodName) + && ! $this->namedScopeMethodExtension->hasMethod($model, $methodName)) { + return []; + } + + // Hypervel: the explicit query is the same query only when the model forwards the call as Model does. + if (! $this->modelRuleHelper->usesModelMethods($model, $node instanceof StaticCall ? self::STATIC_FORWARDING_METHODS : self::FORWARDING_METHODS)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Call to %smethod %s::%s() is forwarded to the query builder.', + $node instanceof StaticCall ? 'static ' : '', + $model->getDisplayName(), + $methodName, + )) + ->tip(sprintf( + 'Use %s->%s() instead.', + $node instanceof StaticCall ? $model->getDisplayName() . '::query()' : 'newQuery()', + $methodName, + )) + ->identifier('hypervel.noImplicitQueryBuilderCall') + ->fixNode($node, static function (StaticCall|MethodCall|NullsafeMethodCall $node): MethodCall { + $builder = match (true) { + $node instanceof StaticCall => new StaticCall($node->class, 'query'), + $node instanceof NullsafeMethodCall => new NullsafeMethodCall($node->var, 'newQuery'), + default => new MethodCall($node->var, 'newQuery'), + }; + + return new MethodCall($builder, $node->name, $node->args); + }) + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/NoLegacyModelPropertiesRule.php b/src/phpstan/src/Rules/NoLegacyModelPropertiesRule.php new file mode 100644 index 0000000000..56c5134141 --- /dev/null +++ b/src/phpstan/src/Rules/NoLegacyModelPropertiesRule.php @@ -0,0 +1,517 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noLegacyModelProperties%')] +class NoLegacyModelPropertiesRule implements Rule +{ + /** + * The attributes the model reads for each setting. + */ + private const array ATTRIBUTES = [ + 'table' => [Table::class], + 'connection' => [Connection::class], + 'primaryKey' => [Table::class], + 'keyType' => [Table::class], + 'incrementing' => [WithoutIncrementing::class, Table::class], + 'timestamps' => [WithoutTimestamps::class, Table::class], + 'dateFormat' => [DateFormat::class, Table::class], + 'fillable' => [Fillable::class], + 'guarded' => [Guarded::class, Unguarded::class], + 'hidden' => [Hidden::class], + 'visible' => [Visible::class], + 'appends' => [Appends::class], + 'touches' => [Touches::class], + 'refreshes' => [Refreshes::class], + ]; + + /** + * The model methods that change each setting when the constructor calls them. + */ + private const array SETTERS = [ + 'table' => ['setTable'], + 'connection' => ['setConnection'], + 'primaryKey' => ['setKeyName'], + 'keyType' => ['setKeyType'], + 'incrementing' => ['setIncrementing'], + 'dateFormat' => ['setDateFormat'], + 'fillable' => ['fillable', 'mergeFillable'], + 'guarded' => ['guard', 'mergeGuarded'], + 'hidden' => ['setHidden', 'mergeHidden', 'makeHidden', 'makeHiddenIf', 'makeVisible', 'makeVisibleIf'], + 'visible' => ['setVisible', 'mergeVisible', 'makeVisible', 'makeVisibleIf'], + 'appends' => ['setAppends', 'mergeAppends', 'append', 'withoutAppends'], + 'touches' => ['setTouchedRelations'], + ]; + + /** + * The setting each Table argument configures. + */ + private const array TABLE_SETTINGS = [ + 'name' => 'table', + 'key' => 'primaryKey', + 'keyType' => 'keyType', + 'incrementing' => 'incrementing', + 'timestamps' => 'timestamps', + 'dateFormat' => 'dateFormat', + ]; + + /** + * The attribute listing each list setting's values, with its parameter. + */ + private const array LISTS = [ + 'fillable' => [Fillable::class, 'columns'], + 'hidden' => [Hidden::class, 'columns'], + 'visible' => [Visible::class, 'columns'], + 'appends' => [Appends::class, 'columns'], + 'touches' => [Touches::class, 'relations'], + 'refreshes' => [Refreshes::class, 'columns'], + ]; + + /** + * Create a new no legacy model properties rule instance. + */ + public function __construct( + private LegacyPropertyHelper $helper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * Report the model's configuration properties that attributes can replace or that can be removed. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + // The base model declares the storage the attributes initialize. + if (! $classReflection->is(Model::class) || $classReflection->getName() === Model::class) { + return []; + } + + // Only the model's own declarations are reported, so a model declaring none has nothing to track. + if (array_all( + array_keys(self::ATTRIBUTES), + static fn (string $property): bool => $classReflection->getNativeReflection()->getProperty($property)->getDeclaringClass()->getName() !== $classReflection->getName(), + )) { + return []; + } + + $attributeClasses = array_values(array_unique(array_merge(...array_values(self::ATTRIBUTES)))); + $this->helper->trackSources($classReflection, $attributeClasses, Model::class, $scope); + + // PHP requires a trait's declaration to match the one the model inherits, so a trait only restates it. + $declarations = array_filter( + $this->helper->declarations($classReflection, $node->getOriginalNode(), array_keys(self::ATTRIBUTES), self::SETTERS), + static fn (array $declaration): bool => $declaration['trait'] === null, + ); + + if ($declarations === []) { + return []; + } + + [$levels, $unknown] = $this->helper->attributeLevels($classReflection, $attributeClasses, Model::class); + $errors = []; + $tableValues = []; + + foreach ($declarations as $property => $declaration) { + $inherited = $this->helper->inheritedDefault($classReflection, $property); + + if ($inherited === null || array_intersect_key(array_flip(self::ATTRIBUTES[$property]), $unknown) !== []) { + continue; + } + + $current = $this->resolve($property, $levels, $declaration['value'], $classReflection->getName()); + + if ($this->helper->same($this->resolve($property, $levels, $inherited['value'], $inherited['declarer']), $current)) { + $errors[] = $this->error(sprintf( + 'Model property %s::$%s should be removed: the model resolves the same value without it.', + $classReflection->getDisplayName(), + $property, + ), $declaration['line'], null); + + continue; + } + + if (in_array($property, ['table', 'primaryKey', 'keyType'], true) || ($property === 'incrementing' && $current === true)) { + $tableValues[$property] = $current; + + continue; + } + + $candidate = $this->candidate($property, $current); + + if ($candidate === null) { + continue; + } + + [$attributeClass, $arguments] = $candidate; + $replaced = $this->place($levels, [$attributeClass => $arguments]); + + if (! $this->helper->same($this->resolve($property, $replaced, $inherited['value'], $inherited['declarer']), $current)) { + continue; + } + + $rendered = sprintf('#[%s%s]', class_basename($attributeClass), $arguments === [] ? '' : '(' . $this->write($current, $declaration) . ')'); + $errors[] = $this->replacementError($classReflection, $property, $declaration['line'], $rendered, isset(self::LISTS[$property]) ? $attributeClass : null); + } + + return [...$errors, ...$this->tableErrors($classReflection, $declarations, $tableValues, $levels, $unknown)]; + } + + /** + * Report the declarations one Table attribute can replace together. + * + * The class has one Table, which replaces an inherited one entirely, so the candidate keeps the nearest Table's + * arguments and is checked against every setting Table configures. + * + * @param array $declarations + * @param array $tableValues the values of the Table-backed declarations, by property + * @param list>, traits: array>>}> $levels + * @param array $unknown + * @return list + */ + private function tableErrors(ClassReflection $classReflection, array $declarations, array $tableValues, array $levels, array $unknown): array + { + if ($tableValues === []) { + return []; + } + + foreach (self::TABLE_SETTINGS as $setting) { + if (array_intersect_key(array_flip(self::ATTRIBUTES[$setting]), $unknown) !== []) { + return []; + } + } + + $arguments = $this->helper->nearestAttribute($levels, Table::class)[0] ?? []; + + foreach ($tableValues as $property => $value) { + $arguments[array_search($property, self::TABLE_SETTINGS, true)] = $property === 'primaryKey' && $value === null ? false : $value; + } + + $replaced = $this->place($levels, [Table::class => $arguments]); + + foreach (self::TABLE_SETTINGS as $setting) { + $currentDefault = $this->settingDefault($classReflection, $declarations, $setting, false); + $replacedDefault = $this->settingDefault($classReflection, $declarations, $setting, array_key_exists($setting, $tableValues)); + + if ( + $currentDefault === null + || $replacedDefault === null + || ! $this->helper->same( + $this->resolve($setting, $replaced, $replacedDefault['value'], $replacedDefault['declarer']), + $this->resolve($setting, $levels, $currentDefault['value'], $currentDefault['declarer']), + ) + ) { + return []; + } + } + + $written = []; + + foreach (self::TABLE_SETTINGS as $parameter => $setting) { + if (($arguments[$parameter] ?? null) === null) { + continue; + } + + $value = array_key_exists($setting, $tableValues) ? $this->write($arguments[$parameter], $declarations[$setting]) : $this->helper->render($arguments[$parameter]); + $written[] = $parameter === 'name' ? $value : $parameter . ': ' . $value; + } + + $rendered = '#[Table' . ($written === [] ? '' : '(' . implode(', ', $written) . ')') . ']'; + $errors = []; + + foreach (array_keys($tableValues) as $property) { + $errors[] = $this->replacementError($classReflection, $property, $declarations[$property]['line'], $rendered, Table::class); + } + + return $errors; + } + + /** + * Get the default and declaring class a setting's property has, with or without the class's own declaration. + * Null when the class declares the property in a way the rule does not read. + * + * @param array $declarations + * @return null|array{value: mixed, declarer: ?string} + */ + private function settingDefault(ClassReflection $classReflection, array $declarations, string $property, bool $removed): ?array + { + if (! $removed && isset($declarations[$property])) { + return ['value' => $declarations[$property]['value'], 'declarer' => $classReflection->getName()]; + } + + if (! $removed && $classReflection->getNativeReflection()->getProperty($property)->getDeclaringClass()->getName() === $classReflection->getName()) { + return null; + } + + return $this->helper->inheritedDefault($classReflection, $property); + } + + /** + * Get the attribute that takes a setting's value, with its arguments. Null when no attribute can. + * + * @return null|array{class-string, array} + */ + private function candidate(string $property, mixed $value): ?array + { + if (isset(self::LISTS[$property])) { + [$attributeClass, $parameter] = self::LISTS[$property]; + + return is_array($value) ? [$attributeClass, [$parameter => [$value]]] : null; + } + + return match ($property) { + 'connection' => $value === null ? null : [Connection::class, ['name' => $value]], + 'incrementing' => $value === false ? [WithoutIncrementing::class, []] : null, + 'timestamps' => $value === false ? [WithoutTimestamps::class, []] : null, + 'dateFormat' => $value === null ? null : [DateFormat::class, ['format' => $value]], + 'guarded' => $value === [] ? [Unguarded::class, []] : (is_array($value) ? [Guarded::class, ['columns' => [$value]]] : null), + default => null, + }; + } + + /** + * Place attributes on the class in place of its own attributes of the same classes. Its other attributes stay, + * as the suggestion leaves them. + * + * @param list>, traits: array>>}> $levels + * @param array> $attributes + * @return list>, traits: array>>}> + */ + private function place(array $levels, array $attributes): array + { + $levels[0]['own'] = $attributes + $levels[0]['own']; + + return $levels; + } + + /** + * Resolve the value the model initializes a setting to, as `Model::initializeModelAttributes()` and the + * concerns' initializers do, from the property's default, its declaring class and the class attributes. + * + * @param key-of $property + * @param list>, traits: array>>}> $levels + */ + private function resolve(string $property, array $levels, mixed $default, ?string $declarer): mixed + { + $table = $this->helper->nearestAttribute($levels, Table::class)[0] ?? null; + $listed = isset(self::LISTS[$property]) ? $this->listedValues($levels, $property) : []; + + return match ($property) { + // The merge*() methods leave the property unchanged for an empty list. + 'fillable', 'hidden', 'visible', 'appends' => $listed === [] ? $default : array_values(array_unique(array_merge($default, $listed))), + 'touches', 'refreshes' => $default === [] ? $listed : $default, + 'table' => ($table['name'] ?? null) !== null && $this->classAttributeOverrides($levels, Table::class, $declarer) + ? $table['name'] + : ($default ?? $table['name'] ?? null), + 'connection' => $this->classAttributeOverrides($levels, Connection::class, $declarer) + ? $this->helper->nearestAttribute($levels, Connection::class)[0]['name'] + : ($default ?? $this->helper->nearestAttribute($levels, Connection::class)[0]['name'] ?? null), + 'primaryKey' => $default === 'id' && ($table['key'] ?? null) !== null + ? ($table['key'] === false ? null : $table['key']) + : $default, + 'keyType' => $default === 'int' ? ($table['keyType'] ?? $default) : $default, + 'incrementing' => $this->helper->nearestAttribute($levels, WithoutIncrementing::class) !== null + ? false + : ($table['incrementing'] ?? $default), + 'timestamps' => $default !== true + ? $default + : ($this->helper->nearestAttribute($levels, WithoutTimestamps::class) === null && ($table['timestamps'] ?? true)), + 'dateFormat' => $default ?? $this->helper->nearestAttribute($levels, DateFormat::class)[0]['format'] ?? $table['dateFormat'] ?? null, + 'guarded' => $this->guardConfiguration($levels, $declarer) ?? $default, + }; + } + + /** + * Determine whether a class attribute replaces the property, as `Model::classAttributeOverridesProperty()` does + * for the default value: a class's own attribute below the property's declaring class. Traits are not read. + * + * @param list>, traits: array>>}> $levels + * @param class-string $attributeClass + */ + private function classAttributeOverrides(array $levels, string $attributeClass, ?string $declarer): bool + { + foreach ($levels as $level) { + if ($level['class'] === $declarer) { + return false; + } + + if (isset($level['own'][$attributeClass])) { + return true; + } + } + + return false; + } + + /** + * Resolve the guarded columns the attributes configure, as `GuardsAttributes::guardConfiguration()` does: each + * class's own Guarded or Unguarded, then its traits', stopping at the property's declaring class. + * + * @param list>, traits: array>>}> $levels + * @return null|array + */ + private function guardConfiguration(array $levels, ?string $declarer): ?array + { + foreach ($levels as $level) { + if ($level['class'] === $declarer) { + return null; + } + + if (isset($level['own'][Guarded::class])) { + return $this->listed($level['own'][Guarded::class]['columns'] ?? []); + } + + if (isset($level['own'][Unguarded::class])) { + return []; + } + + $unguarded = false; + + foreach ($level['traits'] as $attributes) { + if (isset($attributes[Guarded::class])) { + return $this->listed($attributes[Guarded::class]['columns'] ?? []); + } + + $unguarded = $unguarded || isset($attributes[Unguarded::class]); + } + + if ($unguarded) { + return []; + } + } + + return null; + } + + /** + * Get the values the nearest attribute of a list setting holds. + * + * @param list>, traits: array>>}> $levels + * @return array + */ + private function listedValues(array $levels, string $property): array + { + [$attributeClass, $parameter] = self::LISTS[$property]; + + return $this->listed($this->helper->nearestAttribute($levels, $attributeClass)[0][$parameter] ?? []); + } + + /** + * Get the values a list attribute's variadic arguments hold: a first array argument, else the arguments. + * + * @param list $arguments + * @return array + */ + private function listed(array $arguments): array + { + return is_array($arguments[0] ?? null) ? $arguments[0] : $arguments; + } + + /** + * Build the report for a declaration an attribute can replace. + * + * @param null|class-string $replacingAttribute the attribute a subclass's own one replaces entirely, losing inherited values + */ + private function replacementError(ClassReflection $classReflection, string $property, int $line, string $attribute, ?string $replacingAttribute): IdentifierRuleError + { + return $this->error( + sprintf('Model property %s::$%s should be replaced by attribute %s.', $classReflection->getDisplayName(), $property, $attribute), + $line, + $replacingAttribute, + ); + } + + /** + * Write an attribute argument taking a declaration's value: as the declaration writes it when the value is + * unchanged, since an attribute on the class reads the same names, and otherwise as the value. + * + * @param array{value: mixed, expression: ?Expr, line: int, trait: ?ClassReflection, public: bool} $declaration + */ + private function write(mixed $value, array $declaration): string + { + return $declaration['expression'] !== null && $this->helper->same($value, $declaration['value']) + ? $this->helper->printExpression($declaration['expression']) + : $this->helper->render($value); + } + + /** + * Build a report with the guidance on subclasses. + * + * @param null|class-string $replacingAttribute the attribute a subclass's own one replaces entirely, losing inherited values + */ + private function error(string $message, int $line, ?string $replacingAttribute): IdentifierRuleError + { + $error = RuleErrorBuilder::message($message) + ->identifier('hypervel.legacyModelProperty') + ->line($line) + ->addTip("Subclasses' own properties and attributes can take precedence differently: review them, and keep the property where attributes cannot express the hierarchy. See https://hypervel.org/docs/static-analysis#model-configuration"); + + if ($replacingAttribute !== null) { + $error->addTip(sprintf("A subclass's own #[%s] replaces this one entirely, so it must repeat the values it inherits.", class_basename($replacingAttribute))); + } + + return $error->build(); + } +} diff --git a/src/phpstan/src/Rules/NoLegacyModelScopesRule.php b/src/phpstan/src/Rules/NoLegacyModelScopesRule.php new file mode 100644 index 0000000000..0f98fcaf5b --- /dev/null +++ b/src/phpstan/src/Rules/NoLegacyModelScopesRule.php @@ -0,0 +1,76 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noLegacyModelScopes%')] +class NoLegacyModelScopesRule implements Rule +{ + /** + * Create a new no legacy model scopes rule instance. + */ + public function __construct( + private ModelRuleHelper $modelRuleHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + /** + * Report legacy scope methods that the model can declare as attributed scopes. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $method = $node->getMethodReflection(); + + if (! $classReflection->is(Model::class) + || $this->modelRuleHelper->declaresInheritedMethod($classReflection, $method->getName())) { + return []; + } + + $scopeName = $this->modelRuleHelper->attributedScopeName($classReflection, $method); + + if ($scopeName === null) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + "Local query scope method '%s' should be declared as protected #[Scope] method '%s'.", + $method->getName(), + $scopeName, + )) + ->identifier('hypervel.legacyModelScope') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/NoMissingTranslationsRule.php b/src/phpstan/src/Rules/NoMissingTranslationsRule.php new file mode 100644 index 0000000000..505f1cc2dd --- /dev/null +++ b/src/phpstan/src/Rules/NoMissingTranslationsRule.php @@ -0,0 +1,225 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkMissingTranslations%')] +class NoMissingTranslationsRule implements Rule +{ + use HasContainer; + + /** + * Create a new rule instance. + * + * @param string[] $translationDirectories directories that replace the application's translation sources + */ + public function __construct( + private UsedTranslationViewCollector $usedTranslationViewCollector, + private ViewFileHelper $viewFileHelper, + private Filesystem $filesystem, + private RuntimeInputs $runtimeInputs, + #[AutowiredParameter] + private array $translationDirectories, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * Report collected translation lookups the translator cannot resolve. + * + * @param DependencyTracker&Scope $scope + * @return RuleError[] + */ + public function processNode(Node $node, Scope $scope): array + { + $this->runtimeInputs->track($scope); + + $translator = $this->translator(); + $loader = $translator instanceof Translator ? $translator->getLoader() : null; + + // A subclassed or custom translator or loader can resolve lines its own way, such + // as from an external service, so a missing line cannot be established. + if (! $translator instanceof Translator + || $translator::class !== Translator::class + || ! $loader instanceof FileLoader + || $loader::class !== FileLoader::class) { + return []; + } + + // The rule reads the translation files and the views, so the result cache can reuse its errors only while they stay the same. + $this->runtimeInputs->trackLinkedDirectories($scope, [...$loader->paths(), ...$loader->jsonPaths(), ...array_values($loader->namespaces())]); + $this->viewFileHelper->trackRootViewDirectories($scope); + + /** @var array[] $collectors */ + $collectors = [ + $node->get(UsedTranslationFunctionCollector::class), + $node->get(UsedTranslationTranslatorCollector::class), + $node->get(UsedTranslationFacadeCollector::class), + $this->usedTranslationViewCollector->getUsedTranslations(), + ]; + + $usedTranslations = []; + + foreach ($collectors as $collector) { + foreach ($collector as $file => $translations) { + if (! array_key_exists($file, $usedTranslations)) { + $usedTranslations[$file] = []; + } + + $usedTranslations[$file] = array_merge($usedTranslations[$file], $translations); + } + } + + $errors = []; + + foreach ($usedTranslations as $file => $translations) { + foreach ($translations as ['key' => $translation, 'line' => $line, 'locales' => $locales, 'fallback' => $fallback, 'choice' => $choice]) { + foreach ($locales as $locale) { + // The translator throws for a locale that could reach outside the lang directories. + if ($locale !== null && $locale !== '') { + try { + (fn () => $this->ensureValidLocale($locale))->call($translator); + } catch (InvalidArgumentException) { + $errors[] = RuleErrorBuilder::message(sprintf('Translation locale "%s" contains invalid characters.', $locale)) + ->file($file) + ->line($line) + ->identifier('hypervel.invalidTranslationLocale') + ->build(); + + continue; + } + } + + if (! $this->isMissing($translator, $translation, $locale, $fallback, $choice)) { + continue; + } + + $message = $locale === null || $locale === '' + ? sprintf('Translation "%s" has not been found.', $translation) + : sprintf('Translation "%s" has not been found for locale "%s".', $translation, $locale); + + $errors[] = RuleErrorBuilder::message($message) + ->file($file) + ->line($line) + ->identifier('hypervel.missingTranslations') + ->build(); + } + } + } + + return $errors; + } + + /** + * Get the translator the lookups are checked against. + */ + private function translator(): TranslatorContract + { + /** @var TranslatorContract $translator */ + $translator = $this->resolve('translator'); + + if ($this->translationDirectories === []) { + return $translator; + } + + // The configured directories replace the application's translation sources, + // as upstream's do, without changing the application's own translator. + $replacement = new Translator(new FileLoader($this->filesystem, $this->translationDirectories), $translator->getLocale()); + $replacement->setFallback($translator->getFallback()); + + return $replacement; + } + + /** + * Determine whether a structured key with a known group has no translation. + * + * The exact framework translator is checked before this runs, so its protected + * locale resolution, including a determineLocalesUsing() callback, is the one the + * lookup itself uses. + */ + private function isMissing(Translator $translator, string $key, ?string $locale, bool $fallback, bool $choice): bool + { + $locale = $locale ?: $translator->getLocale(); + + // choice() reads the fallback locale's line when the requested locale has none. + if ($choice) { + $locale = (fn (): string => $this->localeForChoice($key, $locale))->call($translator); + } + + if ($translator->has($key, $locale, $fallback)) { + return false; + } + + [$namespace, $group, $item] = $translator->parseKey($key); + $knownGroup = false; + + /** @var list $locales */ + $locales = $fallback ? (fn (): array => $this->localeArray($locale))->call($translator) : [$locale]; + + foreach ($locales as $groupLocale) { + // Unlike get(), getLine() reads the PHP group even when a JSON line has the group's name, + // and runs no missing-key callback. It returns null for a missing or empty group. + $lines = (fn (): array|string|null => $this->getLine($namespace, $group, $groupLocale, null, []))->call($translator); + + if (! is_array($lines)) { + continue; + } + + $knownGroup = true; + + // has() compares the line with the key, so it misses a line equal to its key. + if ($item !== null && Arr::get($lines, $item) === $key) { + return false; + } + } + + return $knownGroup; + } +} diff --git a/src/phpstan/src/Rules/NoModelMakeRule.php b/src/phpstan/src/Rules/NoModelMakeRule.php new file mode 100644 index 0000000000..0aa3d651b1 --- /dev/null +++ b/src/phpstan/src/Rules/NoModelMakeRule.php @@ -0,0 +1,146 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noModelMake%')] +class NoModelMakeRule implements Rule +{ + /** + * The model methods, besides its query's, that make() runs differently from the constructor. + */ + private const array CONSTRUCTION_METHODS = [ + '__construct', + 'newInstance', + 'initializeModelAttributes', + 'getConnectionName', + 'setConnection', + 'getConnection', + 'resolveConnection', + ]; + + /** + * Whether make() creates each model as its constructor does, by class name. + * + * @var array + */ + private array $plainModels = []; + + /** + * Create a new no model make rule instance. + */ + public function __construct( + private ModelRuleHelper $modelRuleHelper, + private ModelHelper $modelHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * Report make() calls on models that the constructor could create instead. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return []; + } + + if ($name->toLowerString() !== 'make') { + return []; + } + + if (! $this->isCalledOnModel($node, $scope)) { + return []; + } + + return [ + RuleErrorBuilder::message("Called 'Model::make()' which performs unnecessary work, use 'new Model()'.") + ->identifier('hypervel.noModelMake') + ->line($node->getStartLine()) + ->file($scope->getFile(), $scope->getFileDescription()) + ->build(), + ]; + } + + /** + * Was the expression called on models that make() creates as their constructors do? + */ + protected function isCalledOnModel(StaticCall $call, Scope $scope): bool + { + $classes = $this->modelRuleHelper->exactCalledClasses($call, $scope); + + return $classes !== null && array_all( + $classes, + fn (ClassReflection $class): bool => $this->plainModels[$class->getName()] ??= $this->makesLikeConstructor($class), + ); + } + + /** + * Determine whether make() creates the model as its constructor does. + * + * The model must build plain queries, construct and resolve its connection as Model does, and have a + * fixed connection other than a read connection, whose writable name make() would pin instead. + */ + private function makesLikeConstructor(ClassReflection $class): bool + { + if (! $class->is(Model::class) + || $class->hasNativeMethod('make') + || ! $this->modelRuleHelper->buildsPlainQueries($class) + || ! $this->modelRuleHelper->usesModelMethods($class, self::CONSTRUCTION_METHODS)) { + return false; + } + + try { + $connection = $this->modelHelper->getInstance($class)->getConnectionName(); + } catch (ReflectionException) { + return false; + } + + return $connection !== null && $connection !== '' && ! ConnectionName::parse($connection)->isRead(); + } +} diff --git a/src/phpstan/src/Rules/NoPublicModelScopeAndAccessorRule.php b/src/phpstan/src/Rules/NoPublicModelScopeAndAccessorRule.php new file mode 100644 index 0000000000..ccafba16fa --- /dev/null +++ b/src/phpstan/src/Rules/NoPublicModelScopeAndAccessorRule.php @@ -0,0 +1,158 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkModelMethodVisibility%')] +class NoPublicModelScopeAndAccessorRule implements Rule +{ + /** + * Create a new no public model scope and accessor rule instance. + */ + public function __construct( + private ModelRuleHelper $modelRuleHelper, + #[AutowiredParameter(ref: '%noLegacyModelScopes%')] + private bool $noLegacyModelScopes, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + /** + * Report model scopes and accessors that are not protected. + * + * @param InClassMethodNode $node + * + * @return list + */ + public function processNode(Node $node, AnalyserScope $scope): array + { + $classReflection = $node->getClassReflection(); + $methodName = $node->getMethodReflection()->getName(); + + if (! $classReflection->is(Model::class)) { + return []; + } + + // Hypervel: an interface's method must stay public, like an inherited one keeps its visibility. + if ($this->modelRuleHelper->declaresInheritedMethod($classReflection, $methodName)) { + return []; + } + + // Hypervel: NoLegacyModelScopesRule reports a legacy scope it can convert, with the protected #[Scope] method to declare. + if ($this->isScopeMethod($node) + && ! ($this->noLegacyModelScopes && $this->modelRuleHelper->attributedScopeName($classReflection, $node->getMethodReflection()) !== null)) { + if (! $node->getOriginalNode()->isProtected()) { + $error = RuleErrorBuilder::message( + sprintf( + "Local query scope method '%s' should be declared as protected.", + $methodName, + ), + ) + ->identifier('hypervel.noPublicModelScopeMethod') + ->line($node->getStartLine()) + ->file($scope->getFile()); + + return [ + ($node->getOriginalNode()->isPrivate() ? $error->fixNode($node->getOriginalNode(), self::makeProtected(...)) : $error)->build(), + ]; + } + } + + if ($this->isAccessorMethod($node)) { + if (! $node->getOriginalNode()->isProtected()) { + $error = RuleErrorBuilder::message( + sprintf( + "Model accessor method '%s' should be declared as protected.", + $methodName, + ), + ) + ->identifier('hypervel.noPublicModelAccessorMethod') + ->line($node->getStartLine()) + ->file($scope->getFile()); + + return [ + ($node->getOriginalNode()->isPrivate() ? $error->fixNode($node->getOriginalNode(), self::makeProtected(...)) : $error)->build(), + ]; + } + } + + return []; + } + + /** + * Determine whether the method is a local scope, as Model::hasNamedScope() finds one. + */ + private function isScopeMethod(InClassMethodNode $method): bool + { + if ($this->modelRuleHelper->isLegacyScopeMethod($method->getMethodReflection())) { + return true; + } + + foreach ($method->getOriginalNode()->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if (strcasecmp($attr->name->toString(), Scope::class) === 0) { + return true; + } + } + } + + return false; + } + + /** + * Determine whether the method is an accessor, whose native return type names Attribute, as + * HasAttributes::hasAttributeMutator() reads it. + */ + private function isAccessorMethod(InClassMethodNode $method): bool + { + $returnType = TypeCombinator::removeNull($method->getMethodReflection()->getVariants()[0]->getNativeReturnType()); + + // A union or intersection is not a ReflectionNamedType, so it is not an accessor. + return $returnType->isObject()->yes() && $returnType->getObjectClassNames() === [Attribute::class]; + } + + /** + * Make a private method protected. + */ + private static function makeProtected(ClassMethod $node): ClassMethod + { + $node->flags &= ~Modifiers::PRIVATE; + $node->flags |= Modifiers::PROTECTED; + + return $node; + } +} diff --git a/src/phpstan/src/Rules/NoUnnecessaryCollectionCallRule.php b/src/phpstan/src/Rules/NoUnnecessaryCollectionCallRule.php new file mode 100644 index 0000000000..672399e3a0 --- /dev/null +++ b/src/phpstan/src/Rules/NoUnnecessaryCollectionCallRule.php @@ -0,0 +1,680 @@ +count() + * could be simplified to: + * User::count() + * + * In addition, this code: + * User::whereStatus('active')->get()->pluck('id') + * could be simplified to: + * User::whereStatus('active')->pluck('id') + * + * Unlike upstream, a call is reported only when the query method returns the same result, which needs the whole + * query in view: a DB::table() query, or a model query whose global scopes, collection and attribute reads are + * the framework's, built from filters, ordering, eager loads, joins and limits and fetched with get() or all(). + * Relations, variables, selects, macros and named scopes stay silent. + * + * @implements Rule + */ +#[Conditional(attribute: new AutowiredService, if: '%noUnnecessaryCollectionCall%')] +class NoUnnecessaryCollectionCallRule implements Rule +{ + /** + * The method names that can be applied on a Collection, but should be applied on a Builder, with the + * query method that returns the same result. + * + * Hypervel: upstream also reports last(), pop(), skip(), slice(), diff(), except(), find(), only(), contains(), + * containsStrict(), firstWhere(), where(), whereStrict() and the aggregates, whose query forms differ: last() + * and pop() have none, skip() and slice() keep keys, diff(), except(), find() and only() compare keys loosely, + * and SQL compares, casts and aggregates values differently from PHP. + */ + protected const array REPLACEMENTS = [ + 'count' => 'count()', + 'first' => 'first()', + 'isempty' => 'doesntExist()', + 'isnotempty' => 'exists()', + 'modelkeys' => 'modelKeys()', + 'pluck' => 'pluck()', + 'take' => 'take()->get()', + ]; + + /** + * The builder methods that only filter the rows a query returns. + */ + private const array FILTERS = [ + 'doesnthave', 'doesnthavemorph', 'has', 'hasmorph', 'ordoesnthave', 'ordoesnthavemorph', 'orhas', 'orhasmorph', + 'orwhere', 'orwhereaftertoday', 'orwhereall', 'orwhereany', 'orwhereattachedto', 'orwherebeforetoday', + 'orwherebelongsto', 'orwherebetween', 'orwherebetweencolumns', 'orwherebinary', 'orwherecolumn', 'orwheredate', + 'orwhereday', 'orwheredoesnthave', 'orwheredoesnthavemorph', 'orwheredoesnthaverelation', 'orwhereexists', + 'orwherefulltext', 'orwherefuture', 'orwherehas', 'orwherehasmorph', 'orwherein', 'orwhereintegerinraw', + 'orwhereintegernotinraw', 'orwherejsoncontains', 'orwherejsoncontainskey', 'orwherejsondoesntcontain', + 'orwherejsondoesntcontainkey', 'orwherejsondoesntoverlap', 'orwherejsonlength', 'orwherejsonoverlaps', + 'orwherekey', 'orwherekeynot', 'orwherelike', 'orwheremonth', 'orwheremorphdoesnthaverelation', + 'orwheremorphedto', 'orwheremorphrelation', 'orwherenone', 'orwherenot', 'orwherenotbetween', + 'orwherenotbetweencolumns', 'orwherenotbinary', 'orwherenotexists', 'orwherenotin', 'orwherenotlike', + 'orwherenotmorphedto', 'orwherenotnull', 'orwherenoworfuture', 'orwherenoworpast', 'orwherenull', + 'orwherenullsafeequals', 'orwherepast', 'orwhereraw', 'orwhererelation', 'orwhererowvalues', 'orwheretime', + 'orwheretoday', 'orwheretodayorafter', 'orwheretodayorbefore', 'orwherevaluebetween', + 'orwherevaluenotbetween', 'orwherevectordistancelessthan', 'orwhereyear', 'where', 'whereaftertoday', + 'whereall', 'whereany', 'whereattachedto', 'wherebeforetoday', 'wherebelongsto', 'wherebetween', + 'wherebetweencolumns', 'wherebinary', 'wherecolumn', 'wheredate', 'whereday', 'wheredoesnthave', + 'wheredoesnthavemorph', 'wheredoesnthaverelation', 'whereexists', 'wherefulltext', 'wherefuture', 'wherehas', + 'wherehasmorph', 'wherein', 'whereintegerinraw', 'whereintegernotinraw', 'wherejsoncontains', + 'wherejsoncontainskey', 'wherejsondoesntcontain', 'wherejsondoesntcontainkey', 'wherejsondoesntoverlap', + 'wherejsonlength', 'wherejsonoverlaps', 'wherekey', 'wherekeynot', 'wherelike', 'wheremonth', + 'wheremorphdoesnthaverelation', 'wheremorphedto', 'wheremorphrelation', 'wherenested', 'wherenone', + 'wherenot', 'wherenotbetween', 'wherenotbetweencolumns', 'wherenotbinary', 'wherenotexists', 'wherenotin', + 'wherenotlike', 'wherenotmorphedto', 'wherenotnull', 'wherenoworfuture', 'wherenoworpast', 'wherenull', + 'wherenullsafeequals', 'wherepast', 'whereraw', 'whererelation', 'whererowvalues', 'wheretime', 'wheretoday', + 'wheretodayorafter', 'wheretodayorbefore', 'wherevaluebetween', 'wherevaluenotbetween', + 'wherevectordistancelessthan', 'wherevectorsimilarto', 'whereyear', 'withwherehas', 'withwhererelation', + ]; + + /** + * The builder methods that order the rows a query returns. + */ + private const array ORDERS = [ + 'inorderof', 'inrandomorder', 'latest', 'oldest', 'orderby', 'orderbydesc', 'orderbyraw', 'orderbyvectordistance', + 'reorder', 'reorderdesc', + ]; + + /** + * The builder methods that choose the relations a model query eager loads. + */ + private const array EAGER_LOADS = ['with', 'without', 'withonly']; + + /** + * The builder methods that join tables, whose columns a model query's default select also fetches. + */ + private const array JOINS = [ + 'crossjoin', 'crossjoinsub', 'join', 'joinlateral', 'joinsub', 'joinwhere', 'leftjoin', 'leftjoinlateral', + 'leftjoinsub', 'leftjoinwhere', 'rightjoin', 'rightjoinsub', 'rightjoinwhere', 'straightjoin', + 'straightjoinsub', 'straightjoinwhere', + ]; + + /** + * The builder methods that limit or offset the rows a query returns. + */ + private const array LIMITS = ['forpage', 'limit', 'offset', 'skip', 'take']; + + /** + * The collection methods a recommended method calls, which a custom collection may override. + */ + private const array DELEGATED_METHODS = [ + 'isnotempty' => ['isEmpty'], + 'pluck' => ['toBase', 'all'], + 'take' => ['slice', 'newInstance'], + ]; + + /** + * The model methods that hydrate a model and read its attributes. + * + * A collection reads each fetched model, while Builder::pluck() returns the fetched value of an attribute + * without a get accessor, cast or date, and otherwise reads it from a model holding that attribute alone. + * So an override, such as a translated attribute value, can change only the collection's result. + */ + private const array ATTRIBUTE_METHODS = [ + 'newFromBuilder', + 'newInstance', + 'setRawAttributes', + 'getAttribute', + 'getAttributeValue', + 'getAttributeFromArray', + 'transformModelValue', + '__get', + 'hasAnyGetMutator', + 'hasCast', + 'getCasts', + 'getDates', + 'castAttribute', + ]; + + /** + * The model methods a collection's pluck() reads attributes through, with data_get(). + */ + private const array PLUCK_METHODS = ['offsetExists', 'offsetGet', '__isset']; + + /** + * The model methods that name and read a model's key, which a collection's modelKeys() reads from each fetched + * model and Builder::modelKeys() names from the query's model. + */ + private const array KEY_METHODS = ['getKey', 'getKeyName', 'getQualifiedKeyName']; + + /** @var string[] The method names that should be checked by this rule - can be configured by the user. */ + protected array $shouldHandle; + + /** + * Whether each model hydrates and reads attributes as Model does, by class name. + * + * @var array + */ + private array $plainAttributeReads = []; + + /** + * Create a new no unnecessary collection call rule instance. + * + * @param string[] $onlyMethods + * @param string[] $excludeMethods + */ + public function __construct( + protected ReflectionProvider $reflectionProvider, + private ModelRuleHelper $modelRuleHelper, + private ModelHelper $modelHelper, + private ModelPropertyHelper $modelPropertyHelper, + private ModelScopeMethodResolver $modelScopeMethodResolver, + private CollectionHelper $collectionHelper, + private RuntimeInputs $runtimeInputs, + #[AutowiredParameter(ref: '%noUnnecessaryCollectionCallOnly%')] + array $onlyMethods, + #[AutowiredParameter(ref: '%noUnnecessaryCollectionCallExcept%')] + array $excludeMethods, + ) { + $allMethods = array_keys(self::REPLACEMENTS); + + if (! empty($onlyMethods)) { + $this->shouldHandle = array_map(static function (string $methodName): string { + return Str::lower($methodName); + }, $onlyMethods); + } else { + $this->shouldHandle = $allMethods; + } + + if (empty($excludeMethods)) { + return; + } + + $this->shouldHandle = array_diff($this->shouldHandle, array_map(static function (string $methodName): string { + return Str::lower($methodName); + }, $excludeMethods)); + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Report collection methods called on a query's results that the query itself could have returned. + * + * @param DependencyTracker&Scope $scope + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Identifier) { + return []; + } + + $name = $node->name; + + if (! in_array($name->toLowerString(), $this->shouldHandle, true) + || ! isset(self::REPLACEMENTS[$name->toLowerString()]) + || ! $this->isReplaceableByQuery($node, $name->toLowerString(), $scope)) { + return []; + } + + return [$this->formatError($name->toString(), self::REPLACEMENTS[$name->toLowerString()])]; + } + + /** + * Determine whether the query the collection was fetched with returns the method's result itself. + * + * @param DependencyTracker&Scope $scope + */ + private function isReplaceableByQuery(MethodCall $call, string $method, Scope $scope): bool + { + $fetch = $call->var; + + if (! ($fetch instanceof MethodCall || $fetch instanceof StaticCall) + || ! $fetch->name instanceof Identifier + || $fetch->isFirstClassCallable()) { + return false; + } + + $fetchName = $fetch->name->toLowerString(); + + // A pluck() fetch keeps one value per row, so it counts and tests rows as the query does. + $fetched = match ($fetchName) { + 'get' => $fetch->getArgs() === [], + 'all' => $fetch instanceof StaticCall && $fetch->getArgs() === [], + 'pluck' => in_array($method, ['count', 'isempty', 'isnotempty'], true) && $this->constantStringArgument($fetch, $scope) !== null, + default => false, + }; + + if (! $fetched) { + return false; + } + + $arguments = $call->getArgs(); + $column = $method === 'pluck' ? $this->constantStringArgument($call, $scope) : null; + $takes = $method === 'take' && count($arguments) === 1 ? $this->constantValues($arguments[0], $scope) : null; + + $accepted = match ($method) { + // A negative take() keeps the last rows, while the query ignores a negative limit. + 'take' => $takes !== null && array_all($takes, static fn (mixed $value): bool => is_int($value) && $value >= 0), + 'pluck' => $column !== null, + default => $arguments === [], + }; + + $query = $accepted ? $this->queryChain($fetch, $scope) : null; + + if ($query === null) { + return false; + } + + // The booted global and query macros decide what a link name calls. + $this->runtimeInputs->track($scope); + + $models = $query['models']; + $joins = false; + $limits = []; + + foreach ($query['links'] as [$linkName, $link]) { + if ($models === null + ? ! $this->isQueryLink($linkName) + : ! array_all($models, fn (ClassReflection $model): bool => $this->isEloquentLink($model, $linkName))) { + return false; + } + + $joins = $joins || in_array(strtolower($linkName), self::JOINS, true); + + if (in_array(strtolower($linkName), self::LIMITS, true)) { + $limits[] = $link; + } + } + + if (! $this->returnsFrameworkCollection($models, $fetchName, $method)) { + return false; + } + + // count() counts the whole query, and take() would replace the query's limit. Builder::pluck() and + // modelKeys() select one unqualified column, while a model query's default select takes joined columns too. + return match ($method) { + 'first' => $this->limitsToAtLeastOneRow(array_reverse($limits), $scope), + 'isempty', 'isnotempty' => true, + 'count', 'take' => $limits === [], + 'pluck' => $models !== null && ! $joins && $column !== null + && array_all($models, fn (ClassReflection $model): bool => $this->plucksLikeCollection($model, $column)), + 'modelkeys' => $models !== null && ! $joins + && array_all($models, fn (ClassReflection $model): bool => $this->plucksKeysLikeCollection($model)), + default => false, + }; + } + + /** + * Get the models a fetch's query is built on (null for a DB::table() query) and the methods building it, + * from the fetch back to the root, or null when the whole query is not in view. + * + * @return null|array{models: null|list, links: list} + */ + private function queryChain(MethodCall|StaticCall $fetch, Scope $scope): ?array + { + $links = []; + $root = $fetch; + + while ($root instanceof MethodCall) { + $root = $root->var; + + if (! ($root instanceof MethodCall || $root instanceof StaticCall) + || ! $root->name instanceof Identifier + || $root->isFirstClassCallable()) { + return null; + } + + if ($this->isTableQuery($root, $scope)) { + return ['models' => null, 'links' => $links]; + } + + if ($root instanceof MethodCall) { + $links[] = [$root->name->toString(), $root]; + } + } + + $models = $this->modelRuleHelper->exactCalledClasses($root, $scope); + + if ($models === null || ! $root->name instanceof Identifier) { + return null; + } + + $name = $root->name->toString(); + $entryPoints = $root === $fetch ? ['all'] : ['query', 'with']; + + foreach ($models as $model) { + if (! $model->is(Model::class) || ! $this->modelRuleHelper->buildsPlainQueries($model)) { + return null; + } + + // Model's own static methods start the query, and any other name reaches it through __callStatic(). + if ($model->hasNativeMethod($name) + && (! in_array(strtolower($name), $entryPoints, true) + || $model->getNativeMethod($name)->getDeclaringClass()->getName() !== Model::class)) { + return null; + } + } + + if ($root !== $fetch && ! in_array(strtolower($name), $entryPoints, true)) { + $links[] = [$name, $root]; + } + + return ['models' => $models, 'links' => $links]; + } + + /** + * Determine whether the call starts a query with DB::table(), or the table() of a DB::connection(). + */ + private function isTableQuery(MethodCall|StaticCall $call, Scope $scope): bool + { + if (! $call->name instanceof Identifier || $call->name->toLowerString() !== 'table' || ! $this->hasConstantArguments($call, $scope)) { + return false; + } + + if ($call instanceof StaticCall) { + return $this->isDatabaseFacade($call); + } + + $connection = $call->var; + + return $connection instanceof StaticCall + && $connection->name instanceof Identifier + && $connection->name->toLowerString() === 'connection' + && $this->isDatabaseFacade($connection) + && $this->hasConstantArguments($connection, $scope); + } + + /** + * Determine whether the static call is on the DB facade. + */ + private function isDatabaseFacade(StaticCall $call): bool + { + return $call->class instanceof Name + && $this->reflectionProvider->hasClass($call->class->toString()) + && $this->reflectionProvider->getClass($call->class->toString())->getName() === DB::class; + } + + /** + * Determine whether each of the call's arguments is a constant value. + */ + private function hasConstantArguments(MethodCall|StaticCall $call, Scope $scope): bool + { + return ! $call->isFirstClassCallable() + && array_all($call->getArgs(), fn (Arg $argument): bool => $this->constantValues($argument, $scope) !== null); + } + + /** + * Determine whether a model query method only filters, orders, eager loads, joins or limits the query, as + * Builder::__call() resolves it. + */ + private function isEloquentLink(ClassReflection $model, string $name): bool + { + $builder = $this->reflectionProvider->getClass(EloquentBuilder::class); + + if ($builder->hasNativeMethod($name)) { + return $builder->getNativeMethod($name)->isPublic() && $this->isListedLink($name); + } + + // A plain model's query has no local macros, and a global macro or named scope runs before the query's method. + return ! EloquentBuilder::hasGlobalMacro($name) + && $this->modelScopeMethodResolver->resolve($model, $name) === null + && $this->isQueryLink($name); + } + + /** + * Determine whether a base query method only filters, orders, joins or limits the query, as Builder::__call() + * resolves it. + */ + private function isQueryLink(string $name): bool + { + $query = $this->reflectionProvider->getClass(QueryBuilder::class); + + if ($query->hasNativeMethod($name)) { + return $query->getNativeMethod($name)->isPublic() && $this->isListedLink($name); + } + + // Any other where... name that no macro takes is a dynamic where. + return ! QueryBuilder::hasMacro($name) && str_starts_with($name, 'where'); + } + + /** + * Determine whether a native builder method is one the rule follows. + */ + private function isListedLink(string $name): bool + { + $name = strtolower($name); + + return in_array($name, self::FILTERS, true) + || in_array($name, self::ORDERS, true) + || in_array($name, self::EAGER_LOADS, true) + || in_array($name, self::JOINS, true) + || in_array($name, self::LIMITS, true); + } + + /** + * Determine whether the query's limit is absent or at least one row, which first() replaces with one row. + * + * @param list $limits the limit and offset calls, in the order they apply + */ + private function limitsToAtLeastOneRow(array $limits, Scope $scope): bool + { + $values = [null]; + + foreach ($limits as $limit) { + $name = $limit->name instanceof Identifier ? $limit->name->toLowerString() : ''; + + if ($name === 'offset' || $name === 'skip') { + continue; + } + + $arguments = $limit->getArgs(); + + // forPage() limits the query to its second argument, 15 rows by default. + $values = $name === 'forpage' && count($arguments) === 1 + ? [15] + : $this->constantValues($arguments[$name === 'forpage' ? 1 : 0] ?? null, $scope); + + if ($values === null || array_any($arguments, static fn (Arg $argument): bool => $argument->name !== null || $argument->unpack)) { + return false; + } + } + + return array_all($values, static fn (mixed $value): bool => $value === null || (is_int($value) && $value >= 1)); + } + + /** + * Determine whether the query fetches the collection with the framework's constructor and methods, since a + * custom collection can count, test or read its models differently. + * + * @param null|list $models + */ + private function returnsFrameworkCollection(?array $models, string $fetch, string $method): bool + { + $collectionNames = [Collection::class]; + + // Builder::get() collects the models with the model's newCollection(), and pluck() returns a base collection. + if ($models !== null && $fetch !== 'pluck') { + $collectionNames = []; + + foreach ($models as $model) { + if (! $this->collectionHelper->usesFrameworkNewCollection($model)) { + return false; + } + + $collectionNames = [...$collectionNames, ...$this->collectionHelper->determineCollectionClass($model->getName())->getObjectClassNames()]; + } + } + + $methods = ['__construct', $method, ...self::DELEGATED_METHODS[$method] ?? []]; + + return $collectionNames !== [] && array_all( + $collectionNames, + fn (string $collectionName): bool => $this->reflectionProvider->hasClass($collectionName) + && CollectionHelper::usesFrameworkMethods($this->reflectionProvider->getClass($collectionName), $methods), + ); + } + + /** + * Determine whether Builder::pluck() returns the attribute's values as the collection reads them from its models. + */ + private function plucksLikeCollection(ClassReflection $model, string $column): bool + { + return $this->modelRuleHelper->usesModelMethods($model, self::PLUCK_METHODS) && $this->readsColumnAsFetched($model, $column); + } + + /** + * Determine whether Builder::modelKeys() returns the keys the collection reads from its models. + */ + private function plucksKeysLikeCollection(ClassReflection $model): bool + { + if (! $this->modelRuleHelper->usesModelMethods($model, self::KEY_METHODS)) { + return false; + } + + try { + $keyName = $this->modelHelper->getInstance($model)->getKeyName(); + } catch (ReflectionException) { + return false; + } + + return $keyName !== null && $this->readsColumnAsFetched($model, $keyName); + } + + /** + * Determine whether a model reads the column as the query fetches it alone: a fetched column whose value no + * get accessor or class cast computes from other attributes. + */ + private function readsColumnAsFetched(ClassReflection $model, string $column): bool + { + return $this->readsAttributesAsModel($model) + && in_array($column, $this->modelPropertyHelper->getDatabasePropertyNames($model) ?? [], true) + && ! $this->modelRuleHelper->isComputedProperty($model, $column); + } + + /** + * Determine whether the model hydrates and reads attributes as Model does, with no retrieved handler that + * could change them. + */ + private function readsAttributesAsModel(ClassReflection $model): bool + { + return $this->plainAttributeReads[$model->getName()] ??= $this->resolvePlainAttributeReads($model); + } + + /** + * Resolve whether the model hydrates and reads attributes as Model does. + */ + private function resolvePlainAttributeReads(ClassReflection $model): bool + { + if (! $this->modelRuleHelper->usesModelMethods($model, self::ATTRIBUTE_METHODS) + || ReflectionHelper::findClassAttribute($model, ObservedBy::class) !== null) { + return false; + } + + try { + $instance = $this->modelHelper->getInstance($model); + } catch (ReflectionException) { + return false; + } + + $dispatcher = Model::getEventDispatcher(); + + return ! isset($instance->dispatchesEvents()['retrieved']) + && ($dispatcher === null || ! $dispatcher->hasListeners('eloquent.retrieved: ' . $model->getName())); + } + + /** + * Get the constant values a positional argument can have, or null when it can have others. + * + * @return null|list + */ + private function constantValues(?Arg $argument, Scope $scope): ?array + { + if ($argument === null || $argument->unpack || $argument->name !== null) { + return null; + } + + $type = $scope->getType($argument->value); + $values = $type->getConstantScalarValues(); + + return $type->isConstantValue()->yes() && $values !== [] ? $values : null; + } + + /** + * Get the call's single argument when it is one constant string. + */ + private function constantStringArgument(MethodCall|StaticCall $call, Scope $scope): ?string + { + $arguments = $call->getArgs(); + $values = count($arguments) === 1 ? $this->constantValues($arguments[0], $scope) : null; + + return $values !== null && count($values) === 1 && is_string($values[0]) ? $values[0] : null; + } + + /** + * Format the error. + */ + protected function formatError(string $methodName, string $replacement): IdentifierRuleError + { + return RuleErrorBuilder::message(sprintf( + "Called '%s' on Hypervel collection, but could have been retrieved as a query with '%s'.", + $methodName, + $replacement, + )) + ->identifier('hypervel.noUnnecessaryCollectionCall') + ->build(); + } +} diff --git a/src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php b/src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php new file mode 100644 index 0000000000..68b6e590b2 --- /dev/null +++ b/src/phpstan/src/Rules/NoUnnecessaryEnumerableToArrayCallsRule.php @@ -0,0 +1,89 @@ +toArray() + * could be simplified to: + * collect([1, 2, 3])->all() + * + * @implements Rule + */ +#[Conditional(attribute: new AutowiredService, if: '%noUnnecessaryEnumerableToArrayCalls%')] +class NoUnnecessaryEnumerableToArrayCallsRule implements Rule +{ + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Report toArray() calls that return the same array as all(). + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $name = $node->name; + + if (! $name instanceof Identifier || $name->toLowerString() !== 'toarray') { + return []; + } + + $calledOnType = $scope->getType($node->var); + + if (! (new ObjectType(Enumerable::class))->isSuperTypeOf($calledOnType)->yes()) { + return []; + } + + $valueType = $calledOnType->getTemplateType(Enumerable::class, 'TValue'); + + if (! (new ObjectType(Arrayable::class))->isSuperTypeOf($valueType)->no()) { + return []; + } + + // Upstream checks only the items. A collection that overrides toArray(), all() or the + // map() that toArray() runs may convert its items differently, as may another Enumerable. + // The Eloquent collection's map() only changes the collection class it returns. + foreach ($calledOnType->getObjectClassReflections() as $classReflection) { + if (! CollectionHelper::usesFrameworkMethods($classReflection, ['toArray', 'all', 'map'])) { + return []; + } + } + + return [ + RuleErrorBuilder::message( + 'Called [toArray()] on an Enumerable which does not contain any Arrayables.', + ) + ->tip('Use [all()] to get the items as an array.') + ->identifier('hypervel.unnecessaryEnumerableToArrayCall') + ->line($name->getStartLine()) + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/Queue/BatchableJobChecksCancellationRule.php b/src/phpstan/src/Rules/Queue/BatchableJobChecksCancellationRule.php new file mode 100644 index 0000000000..5337ed7a67 --- /dev/null +++ b/src/phpstan/src/Rules/Queue/BatchableJobChecksCancellationRule.php @@ -0,0 +1,256 @@ +batch()?->cancelled()` at the start of + * `handle()`, or by registering the `SkipIfBatchCancelled` middleware from + * `middleware()`. + * + * Cancelling a batch (`$batch->cancel()`, or the automatic cancel on first + * failure when the batch is not `allowFailures`) only stops *future* dispatches + * from running their body. Hypervel does not forcibly kill jobs already on the + * queue: each still wakes up and, unless it checks `cancelled()`, runs its full + * body. That is wasted work at best, and at worst it keeps mutating state + * (writing files, calling external APIs, charging cards) for a batch the caller + * has already abandoned. + * + * Each concrete job is checked using its effective methods, including methods + * inherited from parents and traits. Overridden methods do not supply a guard. + * Unlike upstream, which accepts any method named `cancelled()`, a check is + * recognized by type: the batch's `cancelled()` or `canceled()`, the job's + * `batching()`, or the middleware class. The rule reports a missing recognized + * check, not proof that a canceled batch does no work. + * + * @implements Rule + */ +#[Conditional(attribute: new AutowiredService, if: '%checkBatchableJobChecksCancellation%')] +class BatchableJobChecksCancellationRule implements Rule +{ + use InspectsQueuedJobs; + + /** + * Create a new rule instance. + */ + public function __construct( + #[AutowiredParameter(ref: '@cachedRichParser')] + private Parser $parser, + private ScopeFactory $scopeFactory, + private NodeScopeResolver $nodeScopeResolver, + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * Report a batchable queued job without a recognized cancellation check. + * + * @param InClassNode $node + * @param DependencyTracker&Scope $scope + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if (! $this->isDispatchableClass($classReflection)) { + return []; + } + + if (! $classReflection->is(ShouldQueue::class)) { + return []; + } + + if (! $this->usesTrait($classReflection, Batchable::class)) { + return []; + } + + if ($this->guardsCancellation($classReflection, $scope)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'No batch cancellation check was found in batchable job %s.', + $classReflection->getDisplayName(), + )) + ->tip('Check $this->batch()?->cancelled() or $this->batching(), or return the SkipIfBatchCancelled middleware from middleware().') + ->identifier('hypervel.batchableJobChecksCancellation') + ->line($node->getStartLine()) + ->build(), + ]; + } + + /** + * Determine whether one of the job's effective methods checks for batch cancellation. + * + * @param DependencyTracker&Scope $scope + */ + private function guardsCancellation(ClassReflection $classReflection, Scope $scope): bool + { + $finder = new NodeFinder; + $batchableFile = $this->reflectionProvider->getClass(Batchable::class)->getFileName(); + + // The method nodes of each file, by line span, so each file is searched once. They are + // not kept between jobs, which would hold the files' trees beyond PHPStan's parser cache. + $methodNodes = []; + + foreach ($classReflection->getNativeReflection()->getMethods() as $method) { + $fileName = $method->getFileName(); + + // Batchable::batching() checks cancellation itself, but merely using + // the trait does not mean the job calls that helper. + if ($fileName === false || $fileName === $batchableFile) { + continue; + } + + if (! isset($methodNodes[$fileName])) { + $methodNodes[$fileName] = []; + + foreach ($finder->findInstanceOf($this->parser->parseFile($fileName), ClassLike::class) as $candidate) { + foreach ($candidate->getMethods() as $candidateMethod) { + $methodNodes[$fileName][$candidateMethod->getStartLine() . ':' . $candidateMethod->getEndLine()] = [$candidate, $candidateMethod]; + } + } + } + + [$classLike, $methodNode] = $methodNodes[$fileName][$method->getStartLine() . ':' . $method->getEndLine()] ?? [null, null]; + + if ($classLike?->namespacedName === null || $methodNode === null) { + continue; + } + + // A method body is not part of the declarations whose changes reanalyze the job. + if ($fileName !== $scope->getFile()) { + $scope->trackFileDependency($fileName); + } + + $middleware = $finder->findFirst( + $methodNode, + fn (Node $node): bool => ($node instanceof New_ || ($node instanceof ClassConstFetch && $node->name instanceof Identifier && $node->name->toLowerString() === 'class')) + && $node->class instanceof Name + && ! $node->class->isSpecialClassName() + && $this->reflectionProvider->hasClass($node->class->toString()) + && $this->reflectionProvider->getClass($node->class->toString())->is(SkipIfBatchCancelled::class), + ); + + if ($middleware !== null) { + return true; + } + + // Only a method calling one of the check methods needs its types. + $checkCall = $finder->findFirst( + $methodNode, + static fn (Node $node): bool => ($node instanceof MethodCall || $node instanceof NullsafeMethodCall) + && $node->name instanceof Identifier + && in_array($node->name->toLowerString(), ['cancelled', 'canceled', 'batching'], true), + ); + + if ($checkCall !== null && $this->callsCancellationCheck($classLike, $methodNode, $method->getDeclaringClass()->getName(), $fileName)) { + return true; + } + } + + return false; + } + + /** + * Determine whether the method calls the batch's cancellation check or the job's batching(). + * + * The method is analyzed in its declaring class, and in its trait, as PHPStan analyzes it. + */ + private function callsCancellationCheck(ClassLike $classLike, ClassMethod $methodNode, string $declaringClass, string $fileName): bool + { + $methodScope = $this->scopeFactory->create(ScopeContext::create($fileName)); + $namespace = $classLike->namespacedName?->slice(0, -1); + + if ($namespace !== null) { + $methodScope = $methodScope->enterNamespace($namespace->toString()); + } + + $methodScope = $methodScope->enterClass($this->reflectionProvider->getClass($declaringClass)); + + if ($classLike instanceof Trait_ && $classLike->namespacedName !== null) { + // @phpstan-ignore phpstanApi.method (PHPStan enters a trait the same way when it analyzes the trait's methods.) + $methodScope = $methodScope->enterTrait($this->reflectionProvider->getClass($classLike->namespacedName->toString())); + } + + $checks = false; + + $this->nodeScopeResolver->processStmtNodes( + $classLike, + [$methodNode], + $methodScope, + function (Node $node, Scope $scope) use (&$checks): void { + // PHPStan also processes a nullsafe call as a MethodCall on the non-null receiver. + if ($checks || ! $node instanceof MethodCall || ! $node->name instanceof Identifier) { + return; + } + + $receiverType = TypeCombinator::removeNull($scope->getType($node->var)); + + $checks = match ($node->name->toLowerString()) { + 'cancelled', 'canceled' => (new ObjectType(Batch::class))->isSuperTypeOf($receiverType)->yes(), + 'batching' => $receiverType->getObjectClassReflections() !== [] + && array_all( + $receiverType->getObjectClassReflections(), + fn (ClassReflection $receiver): bool => $this->usesTrait($receiver, Batchable::class), + ), + default => false, + }; + }, + StatementContext::createTopLevel(), + ); + + return $checks; + } +} diff --git a/src/phpstan/src/Rules/Queue/BatchedJobIsBatchableRule.php b/src/phpstan/src/Rules/Queue/BatchedJobIsBatchableRule.php new file mode 100644 index 0000000000..4516e8ed9b --- /dev/null +++ b/src/phpstan/src/Rules/Queue/BatchedJobIsBatchableRule.php @@ -0,0 +1,147 @@ +batch()->cancelled()`), and so the batch can + * reconcile its job count and fire its then/catch/finally callbacks. All of that + * lives in `Batchable`, so `PendingBatch` throws when the batch is created with + * a job that does not use it, and `Batch::add()` fails calling its missing + * `withBatchId()`. + * + * Nested arrays represent chains within a batch, and chained jobs need the trait too. + * + * @implements Rule + */ +#[Conditional(attribute: new AutowiredService, if: '%checkBatchedJobIsBatchable%')] +class BatchedJobIsBatchableRule implements Rule +{ + use InspectsQueuedJobs; + + /** + * The batch methods of each facade, keyed by its alias. + * + * @var array}> + */ + private const array FACADE_METHODS = [ + 'Bus' => [Bus::class, ['batch']], + ]; + + /** + * The batch methods each receiver type provides. + * + * Unlike upstream, which checks only Bus::batch(), adding jobs to a batch needs the trait too. + * + * @var array> + */ + private const array RECEIVER_METHODS = [ + QueueingDispatcher::class => ['batch'], + PendingBatch::class => ['add'], + Batch::class => ['add'], + ]; + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report batched jobs that do not use Batchable. + * + * @param CallLike $node + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if ($this->getQueueMethod($node, $scope, self::FACADE_METHODS, self::RECEIVER_METHODS) === null) { + return []; + } + + $jobsArg = $node->getArg('jobs', 0); + + if ($jobsArg === null) { + return []; + } + + $errors = []; + + // Unlike upstream, which checks only array literals, a variable's job type and a single job count too. + foreach ($this->getQueuedJobTypes($jobsArg->value, $node, $scope) as [$type, $reportNode]) { + // Every path the entry may take must hold a job without the trait. + $jobs = $this->getMatchingJobs($type, $this->isNonBatchableJob(...), true); + + if ($jobs === []) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Job %s is batched but does not use Batchable.', + $this->describeJobClasses($jobs), + )) + ->tip('Use the Hypervel\Bus\Batchable trait.') + ->identifier('hypervel.batchedJobIsBatchable') + ->line($reportNode->getStartLine()) + ->build(); + } + + return $errors; + } + + /** + * Determine whether the value is definitely a job that does not use Batchable. + */ + private function isNonBatchableJob(Type $type): bool + { + $reflections = $type->getObjectClassReflections(); + + if ($reflections === []) { + return false; + } + + foreach ($reflections as $classReflection) { + // Unlike upstream, which checks only queued jobs, this follows PendingBatch: every job + // except a closure or a nested batch needs the trait. An interface or abstract class + // may be implemented by a job that uses it. + if ( + ! $this->isDispatchableClass($classReflection) + || $classReflection->is(Closure::class) + || $classReflection->is(PendingBatch::class) + ) { + return false; + } + + if ($this->usesTrait($classReflection, Batchable::class)) { + return false; + } + } + + return true; + } +} diff --git a/src/phpstan/src/Rules/Queue/JobDispatchedInTransactionUsesAfterCommitRule.php b/src/phpstan/src/Rules/Queue/JobDispatchedInTransactionUsesAfterCommitRule.php new file mode 100644 index 0000000000..aae8112218 --- /dev/null +++ b/src/phpstan/src/Rules/Queue/JobDispatchedInTransactionUsesAfterCommitRule.php @@ -0,0 +1,972 @@ +afterCommit()` on the + * dispatch, declaring `$afterCommit = true`, implementing ShouldQueueAfterCommit, + * or using a queue connection that does either. + * + * A queued job pushed during an open transaction can be picked up by a worker + * before the transaction commits (a fast worker racing the still open connection): + * the job then loads rows that are not visible yet and fails, or silently operates + * on half written state. Worse, if the transaction rolls back the job still runs + * against data that never existed. `afterCommit` holds the dispatch until the + * outermost transaction commits, and drops it entirely on rollback. + * + * Unlike upstream, which assumes Laravel's queue config, the rule reports only a + * dispatch whose queue connection is known to push outside the transaction: Hypervel + * defaults `after_commit` to true for every driver except `sync` and `database`, and a + * database queue on the transaction's own connection inserts inside it. Anything the + * analysis cannot establish (an environment-dependent connection, a custom driver, + * failover, an enclosing transaction or a job constructor that changes its settings) + * stays silent, as do a deliberate `beforeCommit()`, the outbox and `afterResponse()`. + * + * Scope and limits: + * + * - Only the transaction callback's closure or arrow function form is inspected. + * The manual `DB::beginTransaction()` ... `DB::commit()` form has no closure + * to bound the analysis and is not covered. + * - `Job::dispatch(...)`, `Job::dispatchIf(...)`, `Job::dispatchUnless(...)`, the + * `dispatch()` helper and `Bus::dispatch()` are checked, for a job named in the + * call. A literal condition that fails dispatches nothing. Synchronous dispatch + * (`dispatchSync`, `dispatch_sync`) runs inline within the transaction by design. + * - A dispatch's own settings only count when they are syntactically chained on it. + * - Unlike upstream, the walk does not enter nested closures, functions or + * classes: they run later, never or at an unknown time (a `DB::afterCommit()` + * callback, for example). A nested transaction is checked from its own call. + * + * @implements Rule + */ +#[Conditional(attribute: new AutowiredService, if: '%checkDispatchInTransactionAfterCommit%')] +class JobDispatchedInTransactionUsesAfterCommitRule implements Rule +{ + use HasContainer; + use InspectsQueuedJobs; + + /** + * The setting each dispatch chain method changes, by lowercase method name. + * + * @var array + */ + private const array CHAIN_SETTINGS = [ + 'aftercommit' => 'afterCommit', + 'beforecommit' => 'afterCommit', + 'onconnection' => 'connection', + 'allonconnection' => 'connection', + 'viaoutbox' => 'viaOutbox', + 'withoutoutbox' => 'viaOutbox', + 'onoutboxdatabase' => 'viaOutbox', + 'afterresponse' => 'afterResponse', + ]; + + /** + * The job properties holding those settings. + * + * @var list + */ + private const array SETTING_PROPERTIES = ['connection', 'afterCommit', 'viaOutbox', 'outboxDatabase']; + + /** + * The Queueable methods a job constructor may call without changing those settings. + * + * @var list + */ + private const array SETTING_FREE_METHODS = ['onqueue', 'allonqueue', 'ongroup', 'withdeduplicator', 'delay', 'withoutdelay', 'through']; + + /** + * The after_commit default each built-in connector applies, for the drivers that can enqueue outside the transaction. + * + * @var array + */ + private const array ASYNC_DRIVERS = [ + 'background' => true, + 'beanstalkd' => true, + 'database' => false, + 'deferred' => true, + 'redis' => true, + 'sqs' => true, + ]; + + /** + * The dispatch settings of each job class and the files they come from, by class name. + * + * @var array}> + */ + private array $jobSettings = []; + + /** + * The booted queue routes, or false when the registry does not resolve. + * + * @var null|array|string>|false + */ + private array|false|null $queueRoutes = null; + + /** + * Whether booted queue forwards exist. + */ + private bool $hasQueueForwards = false; + + /** + * Create a new rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ConfigParser $configParser, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private ConstructorParser $constructorParser, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return Expr\CallLike::class; + } + + /** + * Report queued jobs dispatched inside a transaction that do not wait for it to commit. + * + * @param Expr\CallLike $node + * @param DependencyTracker&Scope $scope + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! ($node instanceof StaticCall || $node instanceof MethodCall) || ! $this->isTransactionCall($node, $scope)) { + return []; + } + + $callback = null; + + foreach ($node->getArgs() as $index => $arg) { + if ($arg->name === null ? $index === 0 : $arg->name->name === 'callback') { + $callback = $arg; + } + } + + $bodyNodes = $callback === null ? [] : $this->closureBody($callback->value); + + if ($bodyNodes === []) { + return []; + } + + $dispatches = []; + $chainCalls = []; + + foreach ($bodyNodes as $bodyNode) { + $this->visit($bodyNode, $scope, $dispatches, $chainCalls); + } + + // An enclosing transaction's connection is unknown, and its open transaction may be the queue's. + $owner = $this->isInDatabaseCall($scope, 'transaction') ? null : $this->getTransactionConnection($node, $scope); + $errors = []; + + foreach ($dispatches as $dispatch) { + $job = $this->getDispatchedJob($dispatch, $scope); + + if ($job === null) { + continue; + } + + $method = $dispatch instanceof StaticCall && $dispatch->name instanceof Identifier ? $dispatch->name->toLowerString() : 'dispatch'; + $condition = $method === 'dispatch' ? null : $dispatch->getArg('boolean', 0); + + if ($condition !== null && $this->getLiteralBoolean($condition, $scope) === [$method === 'dispatchunless']) { + continue; + } + + [$settings, $files] = $this->getJobSettings($job); + + foreach ($files as $file) { + if ($file !== $scope->getFile()) { + $scope->trackFileDependency($file); + } + } + + if ($settings === null || ! $this->dispatchesBeforeCommit($job, $settings, $chainCalls[spl_object_id($dispatch)] ?? [], $owner, $scope)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Job %s is dispatched inside a transaction without deferring until commit.', + $job->getDisplayName(), + )) + ->tip('Call afterCommit() on the dispatch, implement ShouldQueueAfterCommit, or enable after_commit on the queue connection.') + ->identifier('hypervel.dispatchInTransactionAfterCommit') + ->line($dispatch->getStartLine()) + ->build(); + } + + return $errors; + } + + /** + * Determine whether the call runs a transaction: `DB::transaction()` or a connection's `transaction()`. + */ + private function isTransactionCall(StaticCall|MethodCall $node, Scope $scope): bool + { + if (! $node->name instanceof Identifier || $node->name->toLowerString() !== 'transaction') { + return false; + } + + return $node instanceof StaticCall + ? $node->class instanceof Name && $this->isFacade($node->class, DB::class, 'DB') + : (new ObjectType(ConnectionInterface::class))->isSuperTypeOf($scope->getType($node->var))->yes(); + } + + /** + * Get the database connection the transaction runs on, or null when it is unknown. + * + * @param DependencyTracker&Scope $scope + */ + private function getTransactionConnection(StaticCall|MethodCall $node, Scope $scope): ?string + { + if ($node instanceof StaticCall) { + return $this->getDatabaseConnection(null, $scope); + } + + $receiver = $node->var; + + if ( + ! $receiver instanceof StaticCall + || ! $receiver->class instanceof Name + || ! $receiver->name instanceof Identifier + || $receiver->name->toLowerString() !== 'connection' + || ! $this->isFacade($receiver->class, DB::class, 'DB') + ) { + return null; + } + + $nameArgument = $receiver->getArgs()[0] ?? null; + $name = $nameArgument === null ? [null] : $this->getLiteralIdentifier($nameArgument, $scope); + + return $name === null ? null : $this->getDatabaseConnection($name[0], $scope); + } + + /** + * Get the database connection a name requests, resolving the default, or null when it is unknown. + * + * @param DependencyTracker&Scope $scope + */ + private function getDatabaseConnection(?string $name, Scope $scope): ?string + { + if ($name !== null && $name !== '') { + return $name; + } + + // DB::usingConnection() replaces the default while its callback runs. + if ($this->isInDatabaseCall($scope, 'usingConnection')) { + return null; + } + + $default = $this->getConfigValue('database.default', $scope); + + return is_string($default[0] ?? null) && $default[0] !== '' ? $default[0] : null; + } + + /** + * Determine whether the code runs inside a callback of the given database method. + */ + private function isInDatabaseCall(Scope $scope, string $method): bool + { + foreach (CallStack::enclosing($scope) as $reflection) { + if (! $reflection instanceof MethodReflection || strtolower($reflection->getName()) !== strtolower($method)) { + continue; + } + + $class = $reflection->getDeclaringClass(); + + if ($class->is(ConnectionInterface::class) || $class->is(DatabaseManager::class) || $class->is(DB::class)) { + return true; + } + } + + return false; + } + + /** + * The statements or expression that make up the transaction callback's body, + * or an empty list when the callback is not an inspectable closure literal. + * + * @return list + */ + private function closureBody(Expr $callback): array + { + if ($callback instanceof Closure) { + return array_values($callback->stmts); + } + + if ($callback instanceof ArrowFunction) { + return [$callback->expr]; + } + + return []; + } + + /** + * Walk the callback body, recording every dispatch call and the outermost call of + * each setting in its method chain. + * + * @param list $dispatches + * @param array> $chainCalls + */ + private function visit(Node $node, Scope $scope, array &$dispatches, array &$chainCalls): void + { + if ($node instanceof FunctionLike || $node instanceof ClassLike) { + return; + } + + if (($node instanceof MethodCall || $node instanceof NullsafeMethodCall) && $node->name instanceof Identifier) { + $setting = self::CHAIN_SETTINGS[$node->name->toLowerString()] ?? null; + $dispatch = $setting === null ? null : $this->dispatchInReceiverChain($node, $scope); + + if ($setting !== null && $dispatch !== null) { + // The outermost call executes last and is visited first. + $chainCalls[spl_object_id($dispatch)][$setting] ??= $node; + } + } + + if (($node instanceof FuncCall || $node instanceof StaticCall) && $this->isDispatchCall($node, $scope)) { + $dispatches[] = $node; + } + + foreach ($node->getSubNodeNames() as $subNodeName) { + $sub = $node->{$subNodeName}; + $children = is_array($sub) ? $sub : [$sub]; + + foreach ($children as $child) { + if (! $child instanceof Node) { + continue; + } + + $this->visit($child, $scope, $dispatches, $chainCalls); + } + } + } + + /** + * Determine whether the node dispatches a job through Dispatchable's dispatch(), dispatchIf() + * or dispatchUnless(), the dispatch() helper or the Bus facade. + */ + private function isDispatchCall(Node $node, Scope $scope): bool + { + if (! ($node instanceof FuncCall || $node instanceof StaticCall) || $node->isFirstClassCallable()) { + return false; + } + + if ($node instanceof FuncCall) { + return $node->name instanceof Name + && strtolower((string) $this->reflectionProvider->resolveFunctionName($node->name, $scope)) === 'dispatch'; + } + + if ( + ! $node->class instanceof Name + || $node->class->isSpecialClassName() + || ! $node->name instanceof Identifier + || ! in_array($node->name->toLowerString(), ['dispatch', 'dispatchif', 'dispatchunless'], true) + ) { + return false; + } + + // Unlike upstream, Bus::dispatch() is checked too: it queues the job the same way. + if ($this->isFacade($node->class, Bus::class, 'Bus')) { + return $node->name->toLowerString() === 'dispatch'; + } + + $className = $scope->resolveName($node->class); + + if (! $this->reflectionProvider->hasClass($className)) { + return false; + } + + $class = $this->reflectionProvider->getClass($className); + $method = $node->name->toString(); + + // A class's own dispatch method decides how it dispatches. + return $class->hasNativeMethod($method) + && $class->getNativeReflection()->getMethod($method)->getFileName() === $this->reflectionProvider->getClass(Dispatchable::class)->getFileName(); + } + + /** + * Descend a method chain and return the pending dispatch it is applied to, for example + * the `Job::dispatch()` in `Job::dispatch()->onQueue('x')->afterCommit()`. + */ + private function dispatchInReceiverChain(MethodCall|NullsafeMethodCall $chainCall, Scope $scope): FuncCall|StaticCall|null + { + $current = $chainCall; + + while ($current instanceof MethodCall || $current instanceof NullsafeMethodCall) { + $receiver = $current->var; + + if (($receiver instanceof FuncCall || $receiver instanceof StaticCall) && $this->isDispatchCall($receiver, $scope)) { + // Bus::dispatch() returns the handler's result, not a pending dispatch. + return $receiver instanceof StaticCall && $receiver->class instanceof Name && $this->isFacade($receiver->class, Bus::class, 'Bus') + ? null + : $receiver; + } + + $current = $receiver; + } + + return null; + } + + /** + * Get the class of the job a dispatch names, or null when the call does not name one. + * + * Unlike upstream, a job in a variable is not typed: the scope is the transaction call's, + * which does not know the callback's own variables. + */ + private function getDispatchedJob(FuncCall|StaticCall $dispatch, Scope $scope): ?ClassReflection + { + if ($dispatch instanceof StaticCall && $dispatch->class instanceof Name && ! $this->isFacade($dispatch->class, Bus::class, 'Bus')) { + return $this->reflectionProvider->getClass($scope->resolveName($dispatch->class)); + } + + $argument = $dispatch->getArgs()[0] ?? null; + $job = $argument === null || $argument->unpack ? null : $argument->value; + + // The helper queues a closure as a CallQueuedClosure. + if ($job instanceof Closure || $job instanceof ArrowFunction) { + return $dispatch instanceof FuncCall ? $this->reflectionProvider->getClass(CallQueuedClosure::class) : null; + } + + if (! $job instanceof New_ || ! $job->class instanceof Name || $job->class->isSpecialClassName()) { + return null; + } + + $className = $scope->resolveName($job->class); + + return $this->reflectionProvider->hasClass($className) ? $this->reflectionProvider->getClass($className) : null; + } + + /** + * Get the job's dispatch settings and the files they come from. The settings are null + * when the job is not queued through the connection, or its constructor may change them. + * + * @return array{null|array{afterCommit: ?bool, afterCommitContract: bool, viaOutbox: ?bool, outboxContract: bool, connection: ?string, connectionAttribute: ?string, connectionOverridesAttribute: bool}, list} + */ + private function getJobSettings(ClassReflection $job): array + { + if (array_key_exists($job->getName(), $this->jobSettings)) { + return $this->jobSettings[$job->getName()]; + } + + // Property defaults, constructor bodies and the constants attribute arguments read are not + // declarations, whose changes alone reanalyze the dispatching file. + $files = SourceFiles::forInstance($this->reflectionProvider, $job, null); + $attribute = ReflectionHelper::findClassAttribute($job, Connection::class); + + if ($attribute !== null) { + $files = array_values(array_unique(array_merge($files, SourceFiles::forAttributeArguments($this->reflectionProvider, $attribute[2], Connection::class)))); + } + + $afterCommit = $this->getPropertyDefault($job, 'afterCommit'); + $viaOutbox = $this->getPropertyDefault($job, 'viaOutbox'); + $connection = $this->getPropertyDefault($job, 'connection'); + $connectionAttribute = $attribute === null ? [null] : $this->getIdentifier($attribute[0]->getArgumentTypes()['connection'] ?? null); + $connectionProperty = $job->hasNativeProperty('connection') ? $job->getNativeProperty('connection') : null; + + // A job's own queue() method pushes it itself. + if ( + ! $job->is(ShouldQueue::class) + || $job->hasNativeMethod('queue') + || $afterCommit === null || ! ($afterCommit[0] === null || is_bool($afterCommit[0])) + || $viaOutbox === null || ! ($viaOutbox[0] === null || is_bool($viaOutbox[0])) + || $connection === null || ! ($connection[0] === null || is_string($connection[0])) + || $connectionAttribute === null + || $this->constructorChangesSettings($job, $job) + ) { + return $this->jobSettings[$job->getName()] = [null, $files]; + } + + return $this->jobSettings[$job->getName()] = [ + [ + 'afterCommit' => $afterCommit[0], + 'afterCommitContract' => $job->is(ShouldQueueAfterCommit::class), + 'viaOutbox' => $viaOutbox[0], + 'outboxContract' => $job->is(ShouldQueueViaOutbox::class), + 'connection' => $connection[0], + 'connectionAttribute' => $connectionAttribute[0], + 'connectionOverridesAttribute' => $attribute !== null + && $connectionProperty !== null + && $connectionProperty->isPublic() + && $connectionProperty->getDeclaringClass()->isSubclassOfClass($attribute[1]), + ], + $files, + ]; + } + + /** + * Get a property's default value, or null when it is not a constant. A missing or + * uninitialized property reads as null, as isset() does. + * + * @return null|array{null|bool|float|int|string} + */ + private function getPropertyDefault(ClassReflection $class, string $name): ?array + { + if (! $class->hasNativeProperty($name)) { + return [null]; + } + + $property = $class->getNativeProperty($name); + $expression = $property->getNativeReflection()->getBetterReflection()->getDefaultValueExpression(); + + if ($expression === null) { + return [null]; + } + + return $this->getConstantValue($this->initializerExprTypeResolver->getType( + $expression, + InitializerExprContext::fromClassReflection($property->getDeclaringClass()), + )); + } + + /** + * Determine whether the class's constructor, run for the job, may change the job's dispatch settings. + * + * Calls on the job are not interpreted, except the parent constructor and Queueable's + * own setters that change none of them. A parent constructor's calls reach the job's + * own methods, so methods are looked up on the job. + */ + private function constructorChangesSettings(ClassReflection $job, ClassReflection $class): bool + { + $constructor = $this->constructorParser->findConstructor($class); + + if ($constructor === null) { + return false; + } + + [$constructorNode, $declaringClass] = $constructor; + + foreach ($constructorNode->params as $param) { + if ($param->isPromoted() && $param->var instanceof Variable && in_array($param->var->name, self::SETTING_PROPERTIES, true)) { + return true; + } + } + + /** @var list $nodes */ + $nodes = (new NodeFinder)->find($constructorNode->stmts ?? [], static fn (Node $node): bool => $node instanceof Assign || $node instanceof AssignOp || $node instanceof AssignRef || $node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof StaticCall); + + foreach ($nodes as $node) { + if ($node instanceof Assign || $node instanceof AssignOp || $node instanceof AssignRef) { + if ( + ($node->var instanceof PropertyFetch || $node->var instanceof NullsafePropertyFetch) + && $this->isJob($node->var->var) + && (! $node->var->name instanceof Identifier || in_array($node->var->name->toString(), self::SETTING_PROPERTIES, true)) + ) { + return true; + } + + continue; + } + + if ($node instanceof StaticCall) { + if (! $node->class instanceof Name || ! in_array($node->class->toLowerString(), ['self', 'static', 'parent'], true)) { + continue; + } + + if ($node->class->toLowerString() === 'parent' && $node->name instanceof Identifier && $node->name->toLowerString() === '__construct') { + $parent = $declaringClass->getParentClass(); + + if ($parent !== null && $this->constructorChangesSettings($job, $parent)) { + return true; + } + + continue; + } + } elseif (! $this->isJob($node->var)) { + continue; + } + + if ( + ! $node->name instanceof Identifier + || ! in_array($node->name->toLowerString(), self::SETTING_FREE_METHODS, true) + || ! $job->hasNativeMethod($node->name->toString()) + || $job->getNativeReflection()->getMethod($node->name->toString())->getFileName() !== $this->reflectionProvider->getClass(Queueable::class)->getFileName() + ) { + return true; + } + } + + return false; + } + + /** + * Determine whether a constructor expression is the job: `$this`, or a call chain on it. + * + * Each call in the chain is checked on its own, so the chain only continues on the job + * through Queueable's setters, which return it. + */ + private function isJob(Expr $expression): bool + { + while ($expression instanceof MethodCall || $expression instanceof NullsafeMethodCall) { + $expression = $expression->var; + } + + return $expression instanceof Variable && $expression->name === 'this'; + } + + /** + * Determine whether the dispatch pushes the job before the transaction commits, to a queue outside it. + * + * @param array{afterCommit: ?bool, afterCommitContract: bool, viaOutbox: ?bool, outboxContract: bool, connection: ?string, connectionAttribute: ?string, connectionOverridesAttribute: bool} $settings + * @param array $chainCalls + * @param ?string $transactionConnection the transaction's database connection, or null when unknown + * @param DependencyTracker&Scope $scope + */ + private function dispatchesBeforeCommit(ClassReflection $job, array $settings, array $chainCalls, ?string $transactionConnection, Scope $scope): bool + { + $chain = []; + + foreach ($chainCalls as $setting => $call) { + $value = $this->getChainSetting($call, $scope); + + if ($value === null) { + return false; + } + + $chain[$setting] = $value[0]; + } + + // afterResponse() runs the job synchronously once the response is sent. + if (($chain['afterResponse'] ?? false) === true) { + return false; + } + + // Unlike upstream, a deliberate beforeCommit() or $afterCommit = false is respected: it + // is a supported way to dispatch inside the transaction. + if (array_key_exists('afterCommit', $chain) || $settings['afterCommit'] !== null || $settings['afterCommitContract']) { + return false; + } + + $viaOutbox = array_key_exists('viaOutbox', $chain) ? $chain['viaOutbox'] : $settings['viaOutbox']; + + if ($viaOutbox === true || ($viaOutbox === null && $settings['outboxContract'])) { + return false; + } + + $connection = $this->getQueueConnection($job, $settings, $chain, $scope); + + if ($connection === null) { + return false; + } + + // Failover, sync, null and custom drivers are not known to push outside the transaction. + $driver = $this->getConfigValue("queue.connections.{$connection}.driver", $scope)[0] ?? null; + + if (! is_string($driver) || ! isset(self::ASYNC_DRIVERS[$driver])) { + return false; + } + + $afterCommit = $this->getConfigValue("queue.connections.{$connection}.after_commit", $scope, self::ASYNC_DRIVERS[$driver]); + $outbox = $viaOutbox === null ? $this->getConfigValue("queue.connections.{$connection}.outbox", $scope, false) : [false]; + + if (($afterCommit[0] ?? true) !== false || ($outbox[0] ?? true) !== false) { + return false; + } + + if ($driver !== 'database') { + return true; + } + + // A database queue on the transaction's own connection inserts the job inside the transaction. + $queueDatabase = $this->getConfigValue("queue.connections.{$connection}.connection", $scope); + $queueConnection = $queueDatabase !== null && ($queueDatabase[0] === null || is_string($queueDatabase[0])) + ? $this->getDatabaseConnection($queueDatabase[0], $scope) + : null; + + return $transactionConnection !== null + && $queueConnection !== null + && $this->usesSeparateDatabaseConnections($transactionConnection, $queueConnection, $scope); + } + + /** + * Get the value a dispatch chain call sets, or null when it is unknown. + * + * @return null|array{null|bool|string} + */ + private function getChainSetting(MethodCall|NullsafeMethodCall $call, Scope $scope): ?array + { + if ($call->isFirstClassCallable() || ! $call->name instanceof Identifier) { + return null; + } + + $argument = $call->getArgs()[0] ?? null; + + return match ($call->name->toLowerString()) { + 'aftercommit', 'viaoutbox', 'onoutboxdatabase' => [true], + 'beforecommit', 'withoutoutbox' => [false], + 'onconnection', 'allonconnection' => $argument === null ? null : $this->getLiteralIdentifier($argument, $scope), + 'afterresponse' => $argument === null ? [true] : $this->getLiteralBoolean($argument, $scope), + default => null, + }; + } + + /** + * Get the queue connection the job is pushed to, as the dispatcher selects it, or null when it is unknown. + * + * @param array{afterCommit: ?bool, afterCommitContract: bool, viaOutbox: ?bool, outboxContract: bool, connection: ?string, connectionAttribute: ?string, connectionOverridesAttribute: bool} $settings + * @param array $chain + * @param DependencyTracker&Scope $scope + */ + private function getQueueConnection(ClassReflection $job, array $settings, array $chain, Scope $scope): ?string + { + // ReadsClassAttributes::getAttributeValue(): a property changed from its default wins, then the + // attribute unless a public property declared below it overrides it, then the property. + $default = $settings['connection']; + /** @var ?string $property onConnection() and allOnConnection() set a connection name */ + $property = array_key_exists('connection', $chain) ? $chain['connection'] : $default; + $connection = match (true) { + $property !== null && $property !== $default => $property, + $settings['connectionAttribute'] !== null => $settings['connectionOverridesAttribute'] && $property !== null + ? $property + : $settings['connectionAttribute'], + default => $property, + }; + + // Booted routes and forwards may come from the environment, so they never establish a connection. + if ($connection === null) { + $this->runtimeInputs->track($scope); + + if ($this->routesMayChooseConnection($job)) { + return null; + } + } + + if ($connection !== null && $connection !== '') { + return $connection; + } + + $default = $this->getConfigValue('queue.default', $scope); + + return is_string($default[0] ?? null) && $default[0] !== '' ? $default[0] : null; + } + + /** + * Determine whether booted queue routes or forwards may choose the connection of a job without one. + */ + private function routesMayChooseConnection(ClassReflection $job): bool + { + if ($this->queueRoutes === null) { + $routes = $this->resolve('queue.routes'); + $this->queueRoutes = $routes instanceof QueueRoutes ? $routes->all() : false; + $this->hasQueueForwards = $routes instanceof QueueRoutes && (fn (): bool => $this->forwards !== [])->call($routes); + } + + if ($this->queueRoutes === false || $this->hasQueueForwards) { + return true; + } + + // QueueRoutes::getRoute() checks the class, its parents, interfaces and traits in that order. + foreach ([$job->getName(), ...$job->getParentClassesNames(), ...array_keys($job->getInterfaces()), ...array_keys($job->getTraits(true))] as $class) { + if (isset($this->queueRoutes[$class])) { + return is_array($this->queueRoutes[$class]) && ($this->queueRoutes[$class][0] ?? null) !== null; + } + } + + return false; + } + + /** + * Determine whether two requested database connections are held separately. + * + * Each requested name, role included, has its own connection, except role aliases of a + * shared in-memory SQLite database. + * + * @param DependencyTracker&Scope $scope + */ + private function usesSeparateDatabaseConnections(string $first, string $second, Scope $scope): bool + { + if ($first === $second) { + return false; + } + + $base = ConnectionName::parse($first)->base; + + if ($base !== ConnectionName::parse($second)->base) { + return true; + } + + $driver = $this->getConfigValue("database.connections.{$base}.driver", $scope)[0] ?? null; + + return is_string($driver) && $driver !== 'sqlite'; + } + + /** + * Get a config value when it is a known constant, or null when it is unknown. An absent + * key gives the default. + * + * @param DependencyTracker&Scope $scope + * @return null|array{null|bool|float|int|string} + */ + private function getConfigValue(string $key, Scope $scope, bool|string|null $default = null): ?array + { + $value = $this->configParser->getValue($key, $scope); + + if ($value === null) { + return null; + } + + [$presence, $type] = $value; + + if ($presence->no()) { + return [$default]; + } + + return $presence->yes() ? $this->getConstantValue($type) : null; + } + + /** + * Get the identifier a literal argument gives, or null when it is not a literal: the scope is the + * transaction call's, which does not know the callback's own variables. + * + * @return null|array{?string} + */ + private function getLiteralIdentifier(Arg $argument, Scope $scope): ?array + { + return $this->isLiteral($argument) ? $this->getIdentifier($scope->getType($argument->value)) : null; + } + + /** + * Get the boolean a literal argument gives, or null when it is not a literal boolean. + * + * @return null|array{bool} + */ + private function getLiteralBoolean(Arg $argument, Scope $scope): ?array + { + $value = $this->isLiteral($argument) ? $this->getConstantValue($scope->getType($argument->value)) : null; + + return $value !== null && is_bool($value[0]) ? [$value[0]] : null; + } + + /** + * Determine whether the argument is a literal whose type does not depend on the scope. + */ + private function isLiteral(Arg $argument): bool + { + return ! $argument->unpack + && ($argument->value instanceof String_ || $argument->value instanceof ConstFetch || $argument->value instanceof ClassConstFetch); + } + + /** + * Get the connection name a type gives, normalized as enum_value() does, or null when it is unknown. + * + * @return null|array{?string} + */ + private function getIdentifier(?Type $type): ?array + { + if ($type === null) { + return null; + } + + $enumCases = $type->getEnumCases(); + + if (count($enumCases) === 1 && $enumCases[0]->equals($type)) { + $backingValues = $enumCases[0]->getBackingValueType()?->getConstantScalarValues() ?? []; + + return [count($backingValues) === 1 ? (string) $backingValues[0] : $enumCases[0]->getEnumCaseName()]; + } + + $value = $this->getConstantValue($type); + + return $value !== null && ($value[0] === null || is_string($value[0])) ? [$value[0]] : null; + } + + /** + * Get the single constant value of a type, or null when it has none. + * + * @return null|array{null|bool|float|int|string} + */ + private function getConstantValue(Type $type): ?array + { + if ($type->isNull()->yes()) { + return [null]; + } + + $values = $type->getConstantScalarValues(); + + return count($values) === 1 ? [$values[0]] : null; + } +} diff --git a/src/phpstan/src/Rules/Queue/JobWithModelPropertyDeclaresSerializesModelsRule.php b/src/phpstan/src/Rules/Queue/JobWithModelPropertyDeclaresSerializesModelsRule.php new file mode 100644 index 0000000000..41031a3fa5 --- /dev/null +++ b/src/phpstan/src/Rules/Queue/JobWithModelPropertyDeclaresSerializesModelsRule.php @@ -0,0 +1,158 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkJobSerializesModels%')] +class JobWithModelPropertyDeclaresSerializesModelsRule implements Rule +{ + use InspectsQueuedJobs; + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * Report a queued job with model properties that does not use SerializesModels. + * + * @param InClassNode $node + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if (! $this->isDispatchableClass($classReflection)) { + return []; + } + + if (! $classReflection->is(ShouldQueue::class)) { + return []; + } + + if ($this->usesTrait($classReflection, SerializesModels::class) || $classReflection->hasNativeMethod('__serialize')) { + return []; + } + + $modelProperties = $this->modelTypedProperties($classReflection); + + if ($modelProperties === []) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Job %s has model properties (%s) but does not use SerializesModels.', + $classReflection->getDisplayName(), + implode(', ', array_map(static fn (string $name): string => '$' . $name, $modelProperties)), + )) + ->tip('Use the Hypervel\Queue\SerializesModels trait.') + ->identifier('hypervel.jobSerializesModels') + ->line($node->getStartLine()) + ->build(), + ]; + } + + /** + * Get the serialized properties whose type references a model or an Eloquent collection, + * including inherited properties. + * + * An abstract job is not checked itself, so its concrete subclass is checked with all + * of its model state. + * + * @return list + */ + private function modelTypedProperties(ClassReflection $classReflection): array + { + $names = []; + + // A class's own properties exclude its parents' private ones, so each class declares its own. + for ($declaringClass = $classReflection; $declaringClass !== null; $declaringClass = $declaringClass->getParentClass()) { + foreach ($declaringClass->getNativeReflection()->getProperties() as $property) { + if ( + $property->getDeclaringClass()->getName() !== $declaringClass->getName() + || $property->isStatic() + || $property->isVirtual() + ) { + continue; + } + + $type = $declaringClass->getNativeProperty($property->getName())->getReadableType(); + + if ($this->typeReferencesModel($type)) { + $names[] = $property->getName(); + } + } + } + + return array_values(array_unique($names)); + } + + /** + * Determine whether the type references a model or an Eloquent collection. + */ + private function typeReferencesModel(Type $type): bool + { + // A union carrying null reports no object class reflections, so strip it + // first. The result then flattens `Model` and `Foo|Bar` member by member. + foreach (TypeCombinator::removeNull($type)->getObjectClassReflections() as $classReflection) { + if ($classReflection->is(Model::class) || $classReflection->is(EloquentCollection::class)) { + return true; + } + } + + return false; + } +} diff --git a/src/phpstan/src/Rules/Queue/NoBatchedUniqueJobRule.php b/src/phpstan/src/Rules/Queue/NoBatchedUniqueJobRule.php new file mode 100644 index 0000000000..cfd33f9af7 --- /dev/null +++ b/src/phpstan/src/Rules/Queue/NoBatchedUniqueJobRule.php @@ -0,0 +1,146 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noBatchedUniqueJob%')] +class NoBatchedUniqueJobRule implements Rule +{ + use InspectsQueuedJobs; + + /** + * The bulk and batch methods of each facade, keyed by its alias. + * + * @var array}> + */ + private const array FACADE_METHODS = [ + 'Bus' => [Bus::class, ['batch', 'bulk']], + 'Queue' => [Queue::class, ['bulk']], + ]; + + /** + * The bulk and batch methods each receiver type provides. + * + * Unlike upstream, which checks only the facades, the documented $this->batch()->add() and + * bulk calls on dispatcher and queue instances bypass the lock too. + * + * @var array> + */ + private const array RECEIVER_METHODS = [ + QueueingDispatcher::class => ['batch', 'bulk'], + QueueContract::class => ['bulk'], + PendingBatch::class => ['add'], + Batch::class => ['add'], + ]; + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report unique jobs dispatched through a bulk or batch entry point. + * + * @param CallLike $node + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $method = $this->getQueueMethod($node, $scope, self::FACADE_METHODS, self::RECEIVER_METHODS); + $jobsArg = $node->getArg('jobs', 0); + + if ($method === null || $jobsArg === null) { + return []; + } + + $errors = []; + + // Unlike upstream, a single job is checked too: the entry points wrap it. + foreach ($this->getQueuedJobTypes($jobsArg->value, $node, $scope) as [$type, $reportNode]) { + // A unique job on any path the entry may take bypasses the lock. + $jobs = $this->getMatchingJobs($type, $this->isUniqueJobType(...), false); + + if ($jobs === []) { + continue; + } + + $errors[] = RuleErrorBuilder::message($this->buildMessage($jobs, $method)) + ->tip('Dispatch unique jobs individually.') + ->identifier('hypervel.noBatchedUniqueJob') + ->line($reportNode->getStartLine()) + ->build(); + } + + return $errors; + } + + /** + * Determine whether the type is a unique job. + */ + private function isUniqueJobType(Type $type): bool + { + foreach ($type->getObjectClassReflections() as $classReflection) { + if ($this->isDispatchableClass($classReflection) && $classReflection->is(ShouldBeUnique::class)) { + return true; + } + } + + // A value typed as a unique interface or abstract class holds a unique job, whatever its class. + return (new ObjectType(ShouldBeUnique::class))->isSuperTypeOf($type)->yes(); + } + + /** + * Build the message naming the unique jobs and the entry point. + * + * @param list $jobs + */ + private function buildMessage(array $jobs, string $method): string + { + return sprintf( + 'Unique job %s is dispatched via %s().', + $this->describeJobClasses($jobs), + $method, + ); + } +} diff --git a/src/phpstan/src/Rules/Queue/NoLegacyQueuePropertiesRule.php b/src/phpstan/src/Rules/Queue/NoLegacyQueuePropertiesRule.php new file mode 100644 index 0000000000..a852ceaf8c --- /dev/null +++ b/src/phpstan/src/Rules/Queue/NoLegacyQueuePropertiesRule.php @@ -0,0 +1,404 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noLegacyQueueProperties%')] +class NoLegacyQueuePropertiesRule implements Rule +{ + /** + * The attribute each setting's property maps to, with the parameter holding its value. + */ + private const array ATTRIBUTES = [ + 'connection' => [Connection::class, 'connection'], + 'queue' => [QueueAttribute::class, 'queue'], + 'delay' => [Delay::class, 'delay'], + 'tries' => [Tries::class, 'tries'], + 'timeout' => [Timeout::class, 'timeout'], + 'maxExceptions' => [MaxExceptions::class, 'maxExceptions'], + 'backoff' => [Backoff::class, 'backoff'], + 'uniqueFor' => [UniqueFor::class, 'uniqueFor'], + 'debounceFor' => [DebounceFor::class, 'debounceFor'], + 'failOnTimeout' => [FailOnTimeout::class, null], + 'countCrashesAsExceptions' => [CountCrashesAsExceptions::class, null], + 'deleteWhenMissingModels' => [DeleteWhenMissingModels::class, null], + ]; + + /** + * The Queueable methods that change each setting when the constructor calls them. + */ + private const array SETTERS = [ + 'connection' => ['onConnection', 'allOnConnection'], + 'queue' => ['onQueue', 'allOnQueue'], + 'delay' => ['delay', 'withoutDelay'], + ]; + + /** + * Create a new no legacy queue properties rule instance. + */ + public function __construct( + private LegacyPropertyHelper $helper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * Report the queue configuration properties that attributes can replace or that can be removed. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $settings = $this->settings($classReflection); + + // Only the class's own and its traits' declarations are reported, so a class declaring none has nothing to track. + if (array_all( + array_keys($settings), + static fn (string $property): bool => ! $classReflection->hasNativeProperty($property) + || $classReflection->getNativeReflection()->getProperty($property)->getDeclaringClass()->getName() !== $classReflection->getName(), + )) { + return []; + } + + $attributeClasses = array_column(self::ATTRIBUTES, 0); + $this->helper->trackSources($classReflection, $attributeClasses, null, $scope); + + $declarations = $this->helper->declarations($classReflection, $node->getOriginalNode(), array_keys($settings), self::SETTERS); + + if ($declarations === []) { + return []; + } + + [$levels, $unknown] = $this->helper->attributeLevels($classReflection, $attributeClasses, null); + $errors = []; + + foreach ($declarations as $property => $declaration) { + [$attributeClass] = self::ATTRIBUTES[$property]; + $inherited = $this->helper->inheritedDefault($classReflection, $property); + + if ( + ! $declaration['public'] + || ! $this->representable($property, $declaration['value']) + || isset($unknown[$attributeClass]) + || $inherited === null + // The queue cannot read a protected default, but a mailable's own methods can. + || (! $inherited['public'] && $inherited['value'] !== null) + ) { + continue; + } + + $propertyFirst = $settings[$property]; + $current = $this->read($property, $levels, $declaration['value'], $classReflection->getName(), $propertyFirst); + + if ($declaration['trait'] === null && $this->helper->same($this->read($property, $levels, $inherited['value'], $inherited['declarer'], $propertyFirst), $current)) { + $errors[] = $this->error(sprintf( + 'Queue property %s::$%s should be removed: the same value applies without it.', + $classReflection->getDisplayName(), + $property, + ), $classReflection, $declaration, false); + + continue; + } + + // A trait's attribute serves every class using it, so it keeps no maximum wait the class inherits. + $arguments = $this->candidate($property, $declaration['value'], $declaration['trait'] === null ? $levels : []); + $replaced = $this->place($levels, $declaration['trait'], $attributeClass, $arguments); + + if (! $this->helper->same($this->read($property, $replaced, $inherited['value'], $inherited['declarer'], $propertyFirst), $current)) { + continue; + } + + $attribute = $this->renderAttribute($attributeClass, $arguments, $declaration['trait'] === null ? $declaration['expression'] : null); + $message = $declaration['trait'] === null + ? sprintf('Queue property %s::$%s should be replaced by attribute %s.', $classReflection->getDisplayName(), $property, $attribute) + : sprintf('Queue property $%s of trait %s should be replaced by attribute %s on the trait.', $property, $declaration['trait']->getDisplayName(), $attribute); + + $errors[] = $this->error($message, $classReflection, $declaration, $property === 'debounceFor'); + } + + return $errors; + } + + /** + * Get the settings the class's dispatch path reads through attributes, each with whether the path reads a + * set property before the attribute. A setting a method computes on that path is left out. + * + * @return array + */ + private function settings(ClassReflection $classReflection): array + { + $common = ['connection', 'queue', 'delay', 'tries', 'timeout', 'maxExceptions', 'backoff', 'failOnTimeout', 'countCrashesAsExceptions', 'deleteWhenMissingModels']; + $broadcast = false; + + if ($classReflection->is(Mailable::class)) { + // SendQueuedMailable forwards only backoff() to the mailable. + [$settings, $methods] = [$common, ['backoff' => 'backoff']]; + } elseif ($classReflection->is(Notification::class) && $classReflection->is(ShouldQueue::class)) { + [$settings, $methods] = [$common, ['backoff' => 'backoff', 'delay' => 'withDelay']]; + } elseif ($classReflection->is(ShouldBroadcast::class) && ! $classReflection->is(ShouldBroadcastNow::class)) { + $broadcast = true; + $settings = ['connection', 'queue', 'tries', 'timeout', 'backoff', 'maxExceptions', 'deleteWhenMissingModels']; + $methods = ['queue' => 'broadcastQueue', 'uniqueFor' => 'uniqueFor']; + + if ($classReflection->is(ShouldBeUniqueBroadcast::class)) { + $settings[] = 'uniqueFor'; + } + } elseif ($classReflection->is(ShouldQueue::class)) { + // A queued class may be a job or a listener, so either path's methods compute the setting. + $settings = [...$common, 'debounceFor']; + $methods = ['tries' => 'tries', 'backoff' => 'backoff', 'uniqueFor' => 'uniqueFor', 'connection' => 'viaConnection', 'queue' => 'viaQueue', 'delay' => 'withDelay']; + + if ($classReflection->is(ShouldBeUnique::class)) { + $settings[] = 'uniqueFor'; + } + } else { + return []; + } + + $read = []; + + foreach ($settings as $setting) { + if ( + (isset($methods[$setting]) && $classReflection->hasNativeMethod($methods[$setting])) + // A broadcast's $broadcastQueue takes precedence over its queue. + || ($broadcast && $setting === 'queue' && $classReflection->hasNativeProperty('broadcastQueue')) + ) { + continue; + } + + $read[$setting] = $broadcast && in_array($setting, ['connection', 'queue'], true); + } + + return $read; + } + + /** + * Determine whether the setting's attribute can hold the declared value. + */ + private function representable(string $property, mixed $value): bool + { + return match ($property) { + 'connection', 'queue' => is_string($value) || $value instanceof Type, + 'backoff' => is_int($value) || (is_array($value) && array_is_list($value) && array_all($value, static fn (mixed $item): bool => is_int($item))), + 'failOnTimeout', 'countCrashesAsExceptions', 'deleteWhenMissingModels' => $value === true, + default => is_int($value), + }; + } + + /** + * Get the arguments of the attribute taking a setting's value. A debounce keeps the maximum wait of the + * nearest of the given levels' attributes, which the queue reads from the attribute alone. + * + * @param list>, traits: array>>}> $levels + * @return array + */ + private function candidate(string $property, mixed $value, array $levels): array + { + [, $parameter] = self::ATTRIBUTES[$property]; + + return match ($property) { + 'failOnTimeout', 'countCrashesAsExceptions', 'deleteWhenMissingModels' => [], + 'backoff' => ['backoff' => [$value]], + 'debounceFor' => ['debounceFor' => $value, 'maxWait' => $this->helper->nearestAttribute($levels, DebounceFor::class)[0]['maxWait'] ?? null], + default => [$parameter => $value], + }; + } + + /** + * Place an attribute on the class, or on the given trait it uses, in place of its attribute of the same kind. + * + * @param list>, traits: array>>}> $levels + * @param class-string $attributeClass + * @param array $arguments + * @return list>, traits: array>>}> + */ + private function place(array $levels, ?ClassReflection $trait, string $attributeClass, array $arguments): array + { + if ($trait === null) { + $levels[0]['own'][$attributeClass] = $arguments; + } else { + $levels[0]['traits'][$trait->getName()][$attributeClass] = $arguments; + } + + return $levels; + } + + /** + * Read a setting as its dispatch path does, through `ReadsClassAttributes::getAttributeValue()`: a public + * non-null property declared below the nearest attribute's class wins, otherwise the attribute, else the + * property. A broadcast's connection and queue read a set property first. + * + * Connection and queue names compare as strings, as the attributes store them. A debounce reads the maximum + * wait from the attribute alone. + * + * @param list>, traits: array>>}> $levels + */ + private function read(string $property, array $levels, mixed $value, ?string $declarer, bool $propertyFirst): mixed + { + [$attributeClass] = self::ATTRIBUTES[$property]; + $nearest = $this->helper->nearestAttribute($levels, $attributeClass); + $levelIndexes = array_flip(array_column($levels, 'class')); + + if ($nearest === null) { + $setting = $value; + } elseif ($value !== null && $declarer !== null && ($propertyFirst || $levelIndexes[$declarer] < $levelIndexes[$nearest[1]])) { + $setting = $value; + } else { + $setting = $this->attributeValue($property, $nearest[0]); + } + + if ($property === 'debounceFor') { + return [$setting, $nearest[0]['maxWait'] ?? null]; + } + + return $setting instanceof Type ? $this->enumValue($setting) : $setting; + } + + /** + * Get the value `ReadsClassAttributes::extractAttributeValue()` takes from an attribute: its first property. + * + * @param array $arguments + */ + private function attributeValue(string $property, array $arguments): mixed + { + [, $parameter] = self::ATTRIBUTES[$property]; + + if ($parameter === null) { + return true; + } + + if ($property === 'backoff') { + $backoff = $arguments['backoff'] ?? []; + + return count($backoff) === 1 ? $backoff[0] : $backoff; + } + + return $arguments[$parameter] ?? null; + } + + /** + * Get the string an enum case names a connection or queue by, as `enum_value()` does. + */ + private function enumValue(Type $case): string + { + $case = $case->getEnumCases()[0]; + $backingValue = $case->getBackingValueType()?->getConstantScalarValues()[0] ?? null; + + return $backingValue === null ? $case->getEnumCaseName() : (string) $backingValue; + } + + /** + * Write an attribute with its arguments, the setting's value first, as the declaration writes it when given. + * + * @param class-string $attributeClass + * @param array $arguments + */ + private function renderAttribute(string $attributeClass, array $arguments, ?Expr $source): string + { + $written = []; + + foreach ($arguments as $parameter => $value) { + if ($written === []) { + $written[] = $source !== null + ? $this->helper->printExpression($source) + : $this->helper->render($attributeClass === Backoff::class ? $value[0] : $value); + } elseif ($value !== null) { + $written[] = $parameter . ': ' . $this->helper->render($value); + } + } + + return sprintf('#[%s%s]', class_basename($attributeClass), $written === [] ? '' : '(' . implode(', ', $written) . ')'); + } + + /** + * Build a report at the declaration, with the guidance on subclasses. + * + * A trait's declaration is reported in the trait's file, in the context of each class using the trait, + * so one ignore beside it covers them all. + * + * @param array{value: mixed, expression: ?Expr, line: int, trait: ?ClassReflection, public: bool} $declaration + */ + private function error(string $message, ClassReflection $classReflection, array $declaration, bool $replacesInheritedValues): IdentifierRuleError + { + $error = RuleErrorBuilder::message($message) + ->identifier('hypervel.legacyQueueProperty') + ->line($declaration['line']) + ->addTip("Subclasses' own properties and attributes can take precedence differently: review them, and keep the property where attributes cannot express the hierarchy. See https://hypervel.org/docs/static-analysis#queue-configuration"); + + if ($replacesInheritedValues) { + $error->addTip("A subclass's own #[DebounceFor] replaces this one entirely, so it must repeat the maximum wait it inherits."); + } + + $traitFile = $declaration['trait']?->getFileName(); + + if ($traitFile !== null) { + $error->file($traitFile, sprintf('%s (in context of class %s)', $traitFile, $classReflection->getDisplayName())); + } + + return $error->build(); + } +} diff --git a/src/phpstan/src/Rules/Queue/UniqueJobDeclaresUniqueForRule.php b/src/phpstan/src/Rules/Queue/UniqueJobDeclaresUniqueForRule.php new file mode 100644 index 0000000000..fe2c9e1d4d --- /dev/null +++ b/src/phpstan/src/Rules/Queue/UniqueJobDeclaresUniqueForRule.php @@ -0,0 +1,116 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkUniqueJobUniqueFor%')] +class UniqueJobDeclaresUniqueForRule implements Rule +{ + use InspectsQueuedJobs; + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * Report a unique job that does not declare how long it stays unique. + * + * @param InClassNode $node + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if (! $this->isDispatchableClass($classReflection)) { + return []; + } + + if (! $classReflection->is(ShouldBeUnique::class)) { + return []; + } + + // The unique lock calls any uniqueFor() method from outside the job, so the method wins + // over the attribute and property, and must be public. It cannot read a hidden property. + if ($classReflection->hasNativeMethod('uniqueFor')) { + if ($classReflection->getNativeMethod('uniqueFor')->isPublic()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Job %s declares uniqueFor() but does not make it public.', + $classReflection->getDisplayName(), + )) + ->tip('Make uniqueFor() public so the unique lock can call it.') + ->identifier('hypervel.uniqueJobUniqueFor') + ->line($node->getStartLine()) + ->build(), + ]; + } + + if (ReflectionHelper::findClassAttribute($classReflection, UniqueFor::class) !== null) { + return []; + } + + if ($classReflection->hasNativeProperty('uniqueFor')) { + if ($classReflection->getNativeProperty('uniqueFor')->isPublic()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Job %s declares $uniqueFor but does not make it public.', + $classReflection->getDisplayName(), + )) + ->tip('Make $uniqueFor public so the unique lock can read it, or use a #[UniqueFor] attribute.') + ->identifier('hypervel.uniqueJobUniqueFor') + ->line($node->getStartLine()) + ->build(), + ]; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Job %s implements ShouldBeUnique but does not declare uniqueFor.', + $classReflection->getDisplayName(), + )) + ->tip('Add a #[UniqueFor] attribute, or a public uniqueFor() method for a computed duration.') + ->identifier('hypervel.uniqueJobUniqueFor') + ->line($node->getStartLine()) + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/Queue/UniqueJobDeclaresUniqueIdRule.php b/src/phpstan/src/Rules/Queue/UniqueJobDeclaresUniqueIdRule.php new file mode 100644 index 0000000000..d48df4a242 --- /dev/null +++ b/src/phpstan/src/Rules/Queue/UniqueJobDeclaresUniqueIdRule.php @@ -0,0 +1,138 @@ +:` + * and falls back to an empty `uniqueId` when neither is declared (see + * `Hypervel\Bus\UniqueLock::getKey()`). For a job that carries no distinguishing + * state that empty key is correct: the class is a singleton, only one may run at + * a time. But for a job whose constructor takes arguments (per company, per + * product, ...) the empty key collapses *every* dispatch into one unique job + * regardless of those arguments, so legitimately distinct jobs are silently + * dropped at dispatch with no error. + * + * The rule therefore fires only when the job has a constructor with at least one + * parameter. A genuinely class wide unique job satisfies it by declaring + * `uniqueId()` returning a constant, which makes that intent explicit. + * + * @implements Rule + */ +#[Conditional(attribute: new AutowiredService, if: '%checkUniqueJobUniqueId%')] +class UniqueJobDeclaresUniqueIdRule implements Rule +{ + use InspectsQueuedJobs; + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * Report a parameterized unique job that does not declare its identity. + * + * @param InClassNode $node + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if (! $this->isDispatchableClass($classReflection)) { + return []; + } + + if (! $classReflection->is(ShouldBeUnique::class)) { + return []; + } + + if (! $this->hasConstructorParameters($classReflection)) { + // Parameterless job: the class name only lock key is correct. + return []; + } + + // The lock key calls any uniqueId() method from outside the job, so the method wins over + // the property, and must be public. It cannot read a hidden property. + if ($classReflection->hasNativeMethod('uniqueId')) { + if ($classReflection->getNativeMethod('uniqueId')->isPublic()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Unique job %s declares uniqueId() but does not make it public.', + $classReflection->getDisplayName(), + )) + ->tip('Make uniqueId() public so the unique lock can call it.') + ->identifier('hypervel.uniqueJobUniqueId') + ->line($node->getStartLine()) + ->build(), + ]; + } + + if ($classReflection->hasNativeProperty('uniqueId')) { + if ($classReflection->getNativeProperty('uniqueId')->isPublic()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Unique job %s declares $uniqueId but does not make it public.', + $classReflection->getDisplayName(), + )) + ->tip('Make $uniqueId public so the unique lock can read it.') + ->identifier('hypervel.uniqueJobUniqueId') + ->line($node->getStartLine()) + ->build(), + ]; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Unique job %s has constructor parameters but does not declare uniqueId.', + $classReflection->getDisplayName(), + )) + ->tip('Declare a public uniqueId() method or a public $uniqueId property to identify distinct jobs.') + ->identifier('hypervel.uniqueJobUniqueId') + ->line($node->getStartLine()) + ->build(), + ]; + } + + /** + * Determine whether the job's constructor takes parameters. + */ + private function hasConstructorParameters(ClassReflection $classReflection): bool + { + if (! $classReflection->hasConstructor()) { + return false; + } + + $variants = $classReflection->getConstructor()->getVariants(); + + return $variants !== [] && $variants[0]->getParameters() !== []; + } +} diff --git a/src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php b/src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php new file mode 100644 index 0000000000..8e1a89395c --- /dev/null +++ b/src/phpstan/src/Rules/RedisConnectionBoundMethodRule.php @@ -0,0 +1,133 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%checkRedisConnectionBoundMethods%')] +class RedisConnectionBoundMethodRule implements Rule +{ + /** + * The lowercase method names the proxy rejects, as keys. + * + * @var null|array + */ + private ?array $connectionBoundMethods = null; + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Report connection-bound method calls that reach the proxy's __call(). + * + * @param MethodCall $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Identifier) { + return []; + } + + $methodName = $node->name->toString(); + + if (! isset($this->getConnectionBoundMethods()[strtolower($methodName)])) { + return []; + } + + $classReflections = $scope->getType($node->var)->getObjectClassReflections(); + + if ($classReflections === []) { + return []; + } + + foreach ($classReflections as $classReflection) { + if (! $this->reachesProxyCall($classReflection, $methodName, $scope)) { + return []; + } + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Redis connection method %s() must be called on a held Redis connection. Use Redis::withConnection() or Redis::withPinnedConnection().', + $methodName, + ))->identifier('hypervel.redisConnectionBoundMethod')->build(), + ]; + } + + /** + * Determine whether calling the method on the receiver runs the proxy's stock __call(). + */ + private function reachesProxyCall(ClassReflection $classReflection, string $methodName, Scope $scope): bool + { + // A native method the caller can reach runs instead, such as the proxy's own getConnection() calls. + if ($classReflection->hasNativeMethod($methodName) && $scope->canCallMethod($classReflection->getNativeMethod($methodName))) { + return false; + } + + if ($classReflection->is(RedisProxy::class)) { + return $this->declares($classReflection, '__call', RedisProxy::class); + } + + // The manager forwards to the proxy its connection() returns. + return $classReflection->is(RedisManager::class) + && $this->declares($classReflection, '__call', RedisManager::class) + && $this->declares($classReflection, 'connection', RedisManager::class); + } + + /** + * Determine whether the receiver's method is the given class's own, not an override. + */ + private function declares(ClassReflection $classReflection, string $methodName, string $className): bool + { + return $classReflection->getNativeMethod($methodName)->getDeclaringClass()->getName() === $className; + } + + /** + * Get the lowercase method names the proxy rejects, read from the proxy itself. + * + * @return array + */ + private function getConnectionBoundMethods(): array + { + // The constant is private to the proxy, so it is read in the proxy's scope. + return $this->connectionBoundMethods ??= array_fill_keys( + Closure::bind(static fn (): array => self::CONNECTION_BOUND_METHODS, null, RedisProxy::class)(), + true, + ); + } +} diff --git a/src/phpstan/src/Rules/RelationExistenceHelper.php b/src/phpstan/src/Rules/RelationExistenceHelper.php new file mode 100644 index 0000000000..1e981c2d3d --- /dev/null +++ b/src/phpstan/src/Rules/RelationExistenceHelper.php @@ -0,0 +1,200 @@ + + */ + public function check(Type $relations, Type $modelType, Node $node, Scope $scope, bool $aggregate = false, bool $selectsColumns = true, bool $followsPaths = true): array + { + $errors = []; + + foreach (array_unique($this->relationNames($relations, $selectsColumns)) as $name) { + if ($aggregate) { + $alias = explode(' ', $name); + + if (count($alias) === 3 && strtolower($alias[1]) === 'as') { + $name = $alias[0]; + } + } + + $calledOnType = $modelType; + + // Aggregates call the relation method directly; they do not traverse dotted paths. + foreach ($aggregate || ! $followsPaths ? [$name] : explode('.', $name) as $relationName) { + $concreteModel = $this->concreteModel($calledOnType); + + if ($concreteModel === null) { + break; + } + + $modelReflection = $concreteModel->getObjectClassReflections()[0]; + $variant = $modelReflection->hasMethod($relationName) + ? ParametersAcceptorSelector::selectFromArgs($scope, [], $modelReflection->getMethod($relationName, $scope)->getVariants()) + : null; + + // The model calls a relation method without arguments, so one that requires them is not a relation. + if ( + $variant !== null + && array_all($variant->getParameters(), static fn (ParameterReflection $parameter): bool => $parameter->isOptional()) + && (new ObjectType(Relation::class))->isSuperTypeOf($variant->getReturnType())->yes() + ) { + // Simulate a call so dynamic return type extensions can resolve the related model. Unlike upstream, + // the call is on the model's type, keeping a generic model's type arguments. + $calledOnType = $scope->getType(new MethodCall(new TypeExpr($concreteModel), $relationName)); + + continue; + } + + // Unlike upstream, a method without a declared relation return may be a relation of unknown type, which ends the path. + if ($this->modelRelations->isRelationMethod($modelReflection, $relationName)->maybe()) { + break; + } + + $this->runtimeInputs->track($scope); + + // Unlike upstream, a relation resolveRelationUsing() registers exists. A typed one continues the path + // above; one without a declared relation return ends it here. + if (! $this->hasDynamicRelation($modelReflection, $relationName)) { + $errors[$modelReflection->getName() . '::' . $relationName] = RuleErrorBuilder::message(sprintf( + "Relation '%s' is not found in %s model.", + $relationName, + $modelReflection->getName(), + ))->identifier('hypervel.relationExistence')->line($node->getStartLine())->build(); + } + + break; + } + } + + return array_values($errors); + } + + /** + * Return the single concrete model a model, builder or relation type queries, or null when it is not known. + */ + private function concreteModel(Type $type): ?Type + { + $callerClasses = $type->getObjectClassReflections(); + + if (count($callerClasses) !== 1) { + return null; + } + + $modelType = $this->modelRuleHelper->queriedModel($callerClasses[0], $type); + + if ($modelType === null) { + return null; + } + + $modelType = TypeCombinator::removeNull($modelType); + $modelClasses = $modelType->getObjectClassReflections(); + + // An abstract model or a union stands for models whose relations differ. + return count($modelClasses) === 1 && ! $modelClasses[0]->isAbstract() ? $modelType : null; + } + + /** + * Determine whether resolveRelationUsing() registered the relation, reading the registry without running it. + */ + private function hasDynamicRelation(ClassReflection $modelReflection, string $relationName): bool + { + try { + return $this->modelHelper->getRelationResolver($modelReflection, $relationName) !== null; + } catch (ReflectionException) { + // A model that cannot be instantiated has unknown relations. + return true; + } + } + + /** + * Return the relation names an argument lists, with nested names prefixed by their parent's path. + * + * Eager loading parses column selections such as `posts:id` only for names without a callback. A name paired + * with a callback is used verbatim, so it is returned unchanged. + * + * @return list + */ + private function relationNames(Type $type, bool $selectsColumns, string $prefix = ''): array + { + $names = []; + + foreach ($type->getConstantStrings() as $name) { + $names[] = $prefix . ($selectsColumns ? explode(':', $name->getValue())[0] : $name->getValue()); + } + + foreach ($type->getConstantArrays() as $array) { + foreach ($array->getKeyTypes() as $index => $key) { + $value = $array->getValueTypes()[$index]; + + if ($key->isString()->yes()) { + $name = $prefix . $key->getValue(); + $names[] = $value->isArray()->no() ? $name : explode(':', $name)[0]; + + if ($value->isArray()->yes()) { + $names = array_merge($names, $this->relationNames($value, true, explode(':', $name)[0] . '.')); + } + } else { + foreach ($value->getConstantStrings() as $name) { + $names[] = $prefix . explode(':', $name->getValue())[0]; + } + } + } + } + + return $names; + } +} diff --git a/src/phpstan/src/Rules/RelationExistenceRule.php b/src/phpstan/src/Rules/RelationExistenceRule.php new file mode 100644 index 0000000000..b422221fa9 --- /dev/null +++ b/src/phpstan/src/Rules/RelationExistenceRule.php @@ -0,0 +1,163 @@ + + */ +#[AutowiredService] +class RelationExistenceRule implements Rule +{ + // These parse a column selection, such as `posts:id`, from each relation name. + private const array LOADING_METHODS = ['with', 'withonly', 'withwherehas', 'load', 'loadmissing']; + + // These pass the relation name to the existence query as written. + private const array QUERY_METHODS = [ + 'has', + 'orhas', + 'doesnthave', + 'ordoesnthave', + 'wherehas', + 'orwherehas', + 'wheredoesnthave', + 'orwheredoesnthave', + 'whererelation', + 'orwhererelation', + 'withwhererelation', + 'wheredoesnthaverelation', + 'orwheredoesnthaverelation', + ]; + + // These also get the relation by its name directly, so a dotted name is not a path. + private const array MORPH_METHODS = [ + 'hasmorph', + 'orhasmorph', + 'doesnthavemorph', + 'ordoesnthavemorph', + 'wherehasmorph', + 'orwherehasmorph', + 'wheredoesnthavemorph', + 'orwheredoesnthavemorph', + 'wheremorphrelation', + 'orwheremorphrelation', + 'wheremorphdoesnthaverelation', + 'orwheremorphdoesnthaverelation', + ]; + + /** + * Create the rule. + */ + public function __construct( + private ModelRuleHelper $modelRuleHelper, + private RelationExistenceHelper $relationExistenceHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report the relations the call names that the model does not define. + * + * @param DependencyTracker&Scope $scope + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! ($node instanceof MethodCall || $node instanceof StaticCall) || ! $node->name instanceof Identifier) { + return []; + } + + $method = $node->name->toLowerString(); + $aggregate = preg_match('/^(with|load)(aggregate|count|max|min|sum|avg|exists)$/', $method) === 1; + + $morph = in_array($method, self::MORPH_METHODS, true); + $verbatim = $morph || in_array($method, self::QUERY_METHODS, true); + + if (! $aggregate && ! $verbatim && ! in_array($method, self::LOADING_METHODS, true)) { + return []; + } + + $args = $node->getArgs(); + + if ($args === [] || $args[0]->unpack) { + return []; + } + + // with() keeps a name paired with a callback as written. Unlike upstream, withWhereHas() is not + // included: Hypervel's parses the column selection with a callback too. + $selectsColumns = ! $verbatim; + + if ($method === 'with') { + foreach ($args as $position => $arg) { + if ($arg->name === null ? $position !== 1 : $arg->name->toString() !== 'callback') { + continue; + } + + $selectsColumns = ! (new ObjectType(Closure::class))->isSuperTypeOf($scope->getType($arg->value))->yes(); + } + } + + foreach ($args as $arg) { + if ($arg->name !== null && in_array($arg->name->toString(), ['relation', 'relations'], true)) { + $args = [$arg]; + + break; + } + } + + // Unlike upstream, a class-string receiver, as in $modelClass::with(), is checked as its model. + $type = $this->modelRuleHelper->callerType($node, $scope); + $collection = (new ObjectType(Collection::class))->isSuperTypeOf($type)->yes(); + + if ($collection && str_starts_with($method, 'load')) { + $type = $type->getTemplateType(Collection::class, 'TModel'); + } elseif ($collection) { + return []; + } + + $variadic = in_array($method, ['with', 'load', 'loadmissing', 'withcount'], true) + || ($method === 'loadcount' && ! $collection && (new ObjectType(Model::class))->isSuperTypeOf($type)->yes()); + $args = $variadic && $scope->getType($args[0]->value)->isString()->yes() ? $args : [$args[0]]; + $errors = []; + + foreach ($args as $arg) { + if ($arg->unpack) { + continue; + } + + $errors = array_merge($errors, $this->relationExistenceHelper->check($scope->getType($arg->value), $type, $node, $scope, $aggregate, $selectsColumns, ! $morph)); + } + + return $errors; + } +} diff --git a/src/phpstan/src/Rules/UnusableSchemaDumpRule.php b/src/phpstan/src/Rules/UnusableSchemaDumpRule.php new file mode 100644 index 0000000000..b510385d69 --- /dev/null +++ b/src/phpstan/src/Rules/UnusableSchemaDumpRule.php @@ -0,0 +1,66 @@ + + */ +#[AutowiredService] +class UnusableSchemaDumpRule implements Rule +{ + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * Report each unusable schema dump that model property lookups consulted. + * + * @param CollectedDataNode $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + + foreach ($node->get(UnusableSchemaDumpCollector::class) as $collected) { + foreach ($collected as $dumps) { + foreach ($dumps as $dump) { + $errors[$dump['path']] ??= RuleErrorBuilder::message(sprintf('Model property types cannot use this schema dump: %s.', rtrim($dump['message'], '.'))) + ->tip('Fix the dump or regenerate it with schema:dump. Until then, the columns it defines are unknown.') + ->file($dump['path']) + ->line($dump['line']) + ->identifier('hypervel.schemaDump') + ->build(); + } + } + } + + return array_values($errors); + } +} diff --git a/src/phpstan/src/Rules/UnusedViewsRule.php b/src/phpstan/src/Rules/UnusedViewsRule.php new file mode 100644 index 0000000000..1e93e0a891 --- /dev/null +++ b/src/phpstan/src/Rules/UnusedViewsRule.php @@ -0,0 +1,132 @@ + */ +#[Conditional(attribute: new AutowiredService, if: '%checkUnusedViews%')] +class UnusedViewsRule implements Rule +{ + /** @var null|list */ + private ?array $viewsUsedInOtherViews = null; + + /** + * Create a new rule instance. + */ + public function __construct(private UsedViewInAnotherViewCollector $usedViewInAnotherViewCollector, private ViewFileHelper $viewFileHelper) + { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * Report views without a statically discovered usage. + * + * @param DependencyTracker&Scope $scope + * @return RuleError[] + */ + public function processNode(Node $node, Scope $scope): array + { + // The rule reads the view files, so the result cache can reuse its errors only while they stay the same. + $this->viewFileHelper->trackAllViewDirectories($scope); + + if ($this->viewsUsedInOtherViews === null) { + $this->viewsUsedInOtherViews = $this->usedViewInAnotherViewCollector->getUsedViews(); + } + + $usedViews = collect([ + $node->get(UsedViewFunctionCollector::class), + $node->get(UsedEmailViewCollector::class), + $node->get(UsedEmailAlternativeSyntaxViewCollector::class), + $node->get(UsedEmailSendViewCollector::class), + $node->get(UsedViewMakeCollector::class), + $node->get(UsedViewFacadeMakeCollector::class), + $node->get(UsedRouteFacadeViewCollector::class), + $this->viewsUsedInOtherViews, + ])->flatten()->unique()->toArray(); + + /** @var Factory $factory */ + $factory = view(); + $finder = $factory->getFinder(); + + // Unlike upstream, views are compared by file: a view can be found under several names, such + // as an anonymous component in a registered path, whose name holds the path's hashed prefix. + $usedFiles = []; + + foreach ($usedViews as $view) { + if ($factory->exists($view)) { + $usedFiles[self::realPath($finder->find($view))] = true; + } + } + + $errors = []; + + foreach (array_unique(iterator_to_array($this->viewFileHelper->getAllViewNames(), false)) as $view) { + if (! $factory->exists($view)) { + continue; + } + + $path = $finder->find($view); + $file = self::realPath($path); + + if (isset($usedFiles[$file])) { + continue; + } + + // A file reached under another name is reported once. + $usedFiles[$file] = true; + + // Upstream says the view is not used, but only usages with literal view names + // in the analyzed code and views can be found. + $errors[] = RuleErrorBuilder::message('No usage of this view was found.') + ->tip('Views rendered with a dynamic name, or only by code outside the analyzed paths, are not detected.') + ->file($path) + ->line(0) + ->identifier('hypervel.unusedViews') + ->build(); + } + + return $errors; + } + + /** + * Get the file a found view path refers to, which a registered path can spell another way. + */ + private static function realPath(string $path): string + { + return realpath($path) ?: $path; + } +} diff --git a/src/phpstan/src/Rules/UselessConstructs/NoUselessValueFunctionCallsRule.php b/src/phpstan/src/Rules/UselessConstructs/NoUselessValueFunctionCallsRule.php new file mode 100644 index 0000000000..04723e671f --- /dev/null +++ b/src/phpstan/src/Rules/UselessConstructs/NoUselessValueFunctionCallsRule.php @@ -0,0 +1,98 @@ + + */ +#[AutowiredService] +class NoUselessValueFunctionCallsRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * Report value() calls whose value is never a Closure. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Node\Name || strtolower($this->reflectionProvider->resolveFunctionName($node->name, $scope) ?? '') !== 'value') { + return []; + } + + $args = $node->getArgs(); + + foreach ($args as $arg) { + if ($arg->unpack) { + return []; + } + } + + $valueArg = $node->getArg('value', 0); + + if ($valueArg === null) { + return []; + } + + if ($scope->getType($valueArg->value)->isSuperTypeOf(new ClosureType([], new MixedType, true))->no() === false) { + return []; + } + + $error = RuleErrorBuilder::message("Calling the helper function 'value()' without a closure as the first argument simply returns the first argument without doing anything") + ->line($node->getStartLine()) + ->identifier('hypervel.uselessConstructs.value'); + + // value() still evaluates its other arguments, so the call is replaced only when they are null, true or false. + foreach ($args as $arg) { + if ($arg !== $valueArg && ! ($arg->value instanceof ConstFetch && in_array($arg->value->name->toLowerString(), ['null', 'true', 'false'], true))) { + return [$error->build()]; + } + } + + return [$error->fixNode($node, static function (FuncCall $call): Node { + // The fixer passes a copy of the call from its own parse of the file; a node from the + // analyzed tree has positions the fixer's printer cannot use. + /** @var Arg $valueArg */ + $valueArg = $call->getArg('value', 0); + + return $valueArg->value; + })->build()]; + } +} diff --git a/src/phpstan/src/Rules/UselessConstructs/NoUselessWithFunctionCallsRule.php b/src/phpstan/src/Rules/UselessConstructs/NoUselessWithFunctionCallsRule.php new file mode 100644 index 0000000000..3d0b7343d7 --- /dev/null +++ b/src/phpstan/src/Rules/UselessConstructs/NoUselessWithFunctionCallsRule.php @@ -0,0 +1,104 @@ + + */ +#[AutowiredService] +class NoUselessWithFunctionCallsRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * Report with() calls whose callback is missing or null. + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Node\Name || strtolower($this->reflectionProvider->resolveFunctionName($node->name, $scope) ?? '') !== 'with') { + return []; + } + + $args = $node->getArgs(); + + foreach ($args as $arg) { + if ($arg->unpack) { + return []; + } + } + + $valueArg = $node->getArg('value', 0); + + if ($valueArg === null) { + return []; + } + + $callbackArg = $node->getArg('callback', 1); + + // Any other value that is not callable fails the ?callable parameter, so only null returns the value. + if ($callbackArg === null) { + $message = "Calling the helper function 'with()' with only one argument simply returns the value itself. If you want to chain methods on a construct, use '(new ClassName())->foo()' instead"; + } elseif ($scope->getType($callbackArg->value)->isNull()->yes()) { + $message = "Calling the helper function 'with()' without a callable as the second argument simply returns the value without doing anything"; + } else { + return []; + } + + $error = RuleErrorBuilder::message($message) + ->line($node->getStartLine()) + ->identifier('hypervel.uselessConstructs.with'); + + // with() still evaluates its other arguments, so the call is replaced only when they are null, true or false. + foreach ($args as $arg) { + if ($arg !== $valueArg && ! ($arg->value instanceof ConstFetch && in_array($arg->value->name->toLowerString(), ['null', 'true', 'false'], true))) { + return [$error->build()]; + } + } + + return [$error->fixNode($node, static function (FuncCall $call): Node { + // The fixer passes a copy of the call from its own parse of the file; a node from the + // analyzed tree has positions the fixer's printer cannot use. + /** @var Arg $valueArg */ + $valueArg = $call->getArg('value', 0); + + return $valueArg->value; + })->build()]; + } +} diff --git a/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesAttributeRule.php b/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesAttributeRule.php new file mode 100644 index 0000000000..0c63333c94 --- /dev/null +++ b/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesAttributeRule.php @@ -0,0 +1,71 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noPipeSeparatedValidationRules%')] +class NoPipeSeparatedValidationRulesAttributeRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ValidationRuleHelper $helper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return Attribute::class; + } + + /** + * Report the pipe-separated rule strings the attribute takes. + * + * @param Attribute $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $class = $scope->resolveName($node->name); + + if (! $this->reflectionProvider->hasClass($class)) { + return []; + } + + $attribute = $this->reflectionProvider->getClass($class); + + if (! $attribute->is(DataRule::class) || ! $attribute->hasConstructor()) { + return []; + } + + $errors = []; + + foreach ($this->helper->getArgumentValues($node->args, $attribute->getConstructor()->getVariants(), 'rules', $scope) as $value) { + $errors = [...$errors, ...$this->helper->check($value, ValidationRuleHelper::DATA_SET, $scope)]; + } + + return $errors; + } +} diff --git a/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesReturnRule.php b/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesReturnRule.php new file mode 100644 index 0000000000..d67704463e --- /dev/null +++ b/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesReturnRule.php @@ -0,0 +1,77 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noPipeSeparatedValidationRules%')] +class NoPipeSeparatedValidationRulesReturnRule implements Rule +{ + /** + * Create a new rule instance. + */ + public function __construct(private ValidationRuleHelper $helper) + { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + /** + * Report the pipe-separated rule strings in the arrays rules() returns. + * + * @param MethodReturnStatementsNode $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (strtolower($node->getMethodReflection()->getName()) !== 'rules') { + return []; + } + + $shape = match (true) { + $node->getClassReflection()->is(FormRequest::class) => ValidationRuleHelper::MAP, + $node->getClassReflection()->is(ValidateableData::class) => ValidationRuleHelper::DATA_MAP, + default => null, + }; + + if ($shape === null) { + return []; + } + + $errors = []; + + foreach ($node->getReturnStatements() as $statement) { + $value = $statement->getReturnNode()->expr; + + if ($value !== null) { + $errors = [...$errors, ...$this->helper->check($value, $shape, $statement->getScope())]; + } + } + + return $errors; + } +} diff --git a/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesRule.php b/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesRule.php new file mode 100644 index 0000000000..f81922c69c --- /dev/null +++ b/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesRule.php @@ -0,0 +1,296 @@ + + */ +#[Conditional(attribute: new AutowiredService, if: '%noPipeSeparatedValidationRules%')] +class NoPipeSeparatedValidationRulesRule implements Rule +{ + /** + * The parameters taking validation rules, by declaring class and lowercase method name, with the shape of their values. + */ + private const array METHODS = [ + FactoryContract::class => ['make' => ['rules' => ValidationRuleHelper::MAP]], + Factory::class => ['validate' => ['rules' => ValidationRuleHelper::MAP]], + ValidatorFacade::class => [ + 'make' => ['rules' => ValidationRuleHelper::MAP], + 'validate' => ['rules' => ValidationRuleHelper::MAP], + ], + ValidatorContract::class => [ + 'setrules' => ['rules' => ValidationRuleHelper::MAP], + 'sometimes' => ['rules' => ValidationRuleHelper::SET], + ], + Validator::class => ['__construct' => ['rules' => ValidationRuleHelper::MAP]], + Request::class => [ + 'validate' => ['rules' => ValidationRuleHelper::MAP], + 'validatewithbag' => ['rules' => ValidationRuleHelper::MAP], + ], + McpRequest::class => ['validate' => ['rules' => ValidationRuleHelper::MAP]], + ToolRequest::class => ['validate' => ['rules' => ValidationRuleHelper::MAP]], + ValidationRule::class => [ + 'when' => ['rules' => ValidationRuleHelper::SET, 'defaultRules' => ValidationRuleHelper::SET], + 'unless' => ['rules' => ValidationRuleHelper::SET, 'defaultRules' => ValidationRuleHelper::SET], + 'anyof' => ['rules' => ValidationRuleHelper::ANY_OF], + ], + ConditionalRules::class => [ + '__construct' => ['rules' => ValidationRuleHelper::SET, 'defaultRules' => ValidationRuleHelper::SET], + ], + AnyOf::class => ['__construct' => ['rules' => ValidationRuleHelper::ANY_OF]], + DataRule::class => ['__construct' => ['rules' => ValidationRuleHelper::DATA_SET]], + ]; + + /** + * The parameters of ValidatesRequests' methods that take validation rules. + */ + private const array CONTROLLER_METHODS = [ + 'validate' => ['rules' => ValidationRuleHelper::MAP], + 'validatewithbag' => ['rules' => ValidationRuleHelper::MAP], + 'validatewith' => ['validator' => ValidationRuleHelper::MAP], + ]; + + /** + * The lowercase names of the methods that take validation rules. + * + * @var null|array + */ + private ?array $methodNames = null; + + /** + * Create a new rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private ValidationRuleHelper $helper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report the pipe-separated rule strings the call passes. + * + * @param CallLike $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if ($node->isFirstClassCallable()) { + return []; + } + + $entry = match (true) { + $node instanceof FuncCall => $this->matchFunction($node, $scope), + $node instanceof New_ => $this->matchConstructor($node, $scope), + // PHPStan also analyzes a nullsafe call as a method call. + $node instanceof MethodCall, $node instanceof StaticCall => $this->matchMethod($node, $scope), + default => null, + }; + + if ($entry === null) { + return []; + } + + $errors = []; + + foreach ($entry['parameters'] as $parameter => $shape) { + foreach ($this->helper->getArgumentValues($node->getArgs(), $entry['variants'], $parameter, $scope) as $value) { + array_push($errors, ...$this->helper->check($value, $shape, $scope)); + } + } + + return $errors; + } + + /** + * Match validator() and the prompt functions. + * + * @return null|array{variants: list, parameters: array} + */ + private function matchFunction(FuncCall $call, Scope $scope): ?array + { + if (! $call->name instanceof Name) { + return null; + } + + $name = strtolower($this->reflectionProvider->resolveFunctionName($call->name, $scope) ?? ''); + + $parameters = match (true) { + $name === 'validator' => ['rules' => ValidationRuleHelper::MAP], + str_starts_with($name, 'hypervel\prompts\\') => ['validate' => ValidationRuleHelper::PROMPT], + default => null, + }; + + return $parameters === null + ? null + : ['variants' => $this->reflectionProvider->getFunction($call->name, $scope)->getVariants(), 'parameters' => $parameters]; + } + + /** + * Match a constructor taking validation rules. + * + * @return null|array{variants: list, parameters: array} + */ + private function matchConstructor(New_ $new, Scope $scope): ?array + { + if (! $new->class instanceof Name) { + return null; + } + + $class = $scope->resolveName($new->class); + + if (! $this->reflectionProvider->hasClass($class) || ! $this->reflectionProvider->getClass($class)->hasConstructor()) { + return null; + } + + $constructor = $this->reflectionProvider->getClass($class)->getConstructor(); + $parameters = $this->getParameters($constructor->getDeclaringClass(), '__construct'); + + return $parameters === [] ? null : ['variants' => $constructor->getVariants(), 'parameters' => $parameters]; + } + + /** + * Match a method taking validation rules, filtering by name before reflecting the method. + * + * @return null|array{variants: list, parameters: array} + */ + private function matchMethod(MethodCall|StaticCall $call, Scope $scope): ?array + { + if (! $call->name instanceof Identifier) { + return null; + } + + $name = $call->name->toLowerString(); + + if (! isset($this->getMethodNames()[$name])) { + return null; + } + + $type = match (true) { + $call instanceof StaticCall => $call->class instanceof Name ? $scope->resolveTypeByName($call->class) : $scope->getType($call->class), + default => $scope->getType($call->var), + }; + + $method = $scope->getMethodReflection($type, $call->name->name); + $parameters = $method === null ? [] : $this->getParameters($method->getDeclaringClass(), $name); + + return $method === null || $parameters === [] ? null : ['variants' => $method->getVariants(), 'parameters' => $parameters]; + } + + /** + * Get the lowercase names of the methods that take validation rules, so other calls skip reflection. + * + * @return array + */ + private function getMethodNames(): array + { + if ($this->methodNames !== null) { + return $this->methodNames; + } + + $names = array_fill_keys(array_keys(self::CONTROLLER_METHODS), true); + + foreach (self::METHODS as $methods) { + $names += array_fill_keys(array_keys($methods), true); + } + + if ($this->reflectionProvider->hasClass(FormBuilder::class)) { + foreach ($this->reflectionProvider->getClass(FormBuilder::class)->getNativeReflection()->getMethods() as $method) { + foreach ($method->getParameters() as $parameter) { + if ($parameter->getName() === 'validate') { + $names[strtolower($method->getName())] = true; + } + } + } + } + + return $this->methodNames = $names; + } + + /** + * Get the parameters of the class's method that take validation rules, with the shape of their values. + * + * @return array + */ + private function getParameters(ClassReflection $class, string $method): array + { + foreach (self::METHODS as $entry => $methods) { + if (isset($methods[$method]) && $class->is($entry)) { + return $methods[$method]; + } + } + + // Each prompt declares its own constructor, and the form builder's prompt methods take the same argument. + if ($class->is(FormBuilder::class) || ($method === '__construct' && $class->is(Prompt::class))) { + return ['validate' => ValidationRuleHelper::PROMPT]; + } + + return isset(self::CONTROLLER_METHODS[$method]) && $this->isDeclaredByValidatesRequests($class, $method) + ? self::CONTROLLER_METHODS[$method] + : []; + } + + /** + * Determine whether the class's method is ValidatesRequests' own, not an override. + */ + private function isDeclaredByValidatesRequests(ClassReflection $class, string $method): bool + { + $native = $class->getNativeReflection(); + + return $this->reflectionProvider->hasClass(ValidatesRequests::class) + && $native->hasMethod($method) + && $native->getMethod($method)->getFileName() === $this->reflectionProvider->getClass(ValidatesRequests::class)->getFileName(); + } +} diff --git a/src/phpstan/src/Rules/Validation/ValidationRuleHelper.php b/src/phpstan/src/Rules/Validation/ValidationRuleHelper.php new file mode 100644 index 0000000000..a91ce410b7 --- /dev/null +++ b/src/phpstan/src/Rules/Validation/ValidationRuleHelper.php @@ -0,0 +1,343 @@ + $arguments + * @param list $variants + * + * @return list + */ + public function getArgumentValues(array $arguments, array $variants, string $parameter, Scope $scope): array + { + if (array_any($arguments, static fn (Arg $argument): bool => $argument->unpack)) { + return []; + } + + $parameters = ParametersAcceptorSelector::selectFromArgs($scope, $arguments, $variants)->getParameters(); + $last = $parameters === [] ? null : $parameters[array_key_last($parameters)]; + $values = []; + + // Positional arguments come first, and those past the last parameter belong to it when it is variadic. + foreach (array_values($arguments) as $position => $argument) { + $positional = $parameters[$position] ?? ($last?->isVariadic() ? $last : null); + + if (($argument->name?->toString() ?? $positional?->getName()) === $parameter) { + $values[] = $argument->value; + } + } + + return $values; + } + + /** + * Report the rule strings in a validation value of the given shape that are split at "|". + * + * @param Shape $shape + * + * @return list + */ + public function check(Expr $value, string $shape, Scope $scope): array + { + return match ($shape) { + self::MAP => $this->checkItems($value, self::SET, $scope), + self::DATA_MAP => $this->checkItems($value, self::DATA_SET, $scope), + self::SET => $this->checkString($value, false, $scope), + self::DATA_SET => $value instanceof Array_ + ? $this->checkItems($value, self::DATA_SET, $scope) + : $this->checkString($value, true, $scope), + self::ANY_OF => $this->checkAlternatives($value, $scope), + self::PROMPT => $this->checkPrompt($value, $scope), + }; + } + + /** + * Report the split rule strings among the values of an array literal. + * + * @param Shape $shape + * + * @return list + */ + private function checkItems(Expr $value, string $shape, Scope $scope): array + { + if (! $value instanceof Array_) { + return []; + } + + $errors = []; + + foreach ($value->items as $item) { + if (! $item->unpack) { + array_push($errors, ...$this->check($item->value, $shape, $scope)); + } + } + + return $errors; + } + + /** + * Report the split rule strings among Rule::anyOf()'s alternatives, read as AnyOf::passes() reads them. + * + * @return list + */ + private function checkAlternatives(Expr $value, Scope $scope): array + { + if (! $value instanceof Array_) { + return []; + } + + $errors = []; + + foreach ($value->items as $item) { + if ($item->unpack) { + continue; + } + + $alternative = $item->value; + + array_push($errors, ...match (true) { + ! $alternative instanceof Array_ => $this->checkString($alternative, false, $scope), + $scope->getType($alternative)->isList()->no() => $this->check($alternative, self::MAP, $scope), + default => [], + }); + } + + return $errors; + } + + /** + * Report a split rule string given as a prompt's rules, read as validatePrompt() reads them. + * + * @return list + */ + private function checkPrompt(Expr $value, Scope $scope): array + { + if (! $value instanceof Array_) { + return $this->checkString($value, false, $scope); + } + + // A keyed array names the attribute with its first key and gives the rules as its first value. + $first = $value->items[0] ?? null; + + return $first !== null && ! $first->unpack && $scope->getType($value)->isList()->no() + ? $this->checkString($first->value, false, $scope) + : []; + } + + /** + * Report a rule string that is split at "|". + * + * @return list + */ + private function checkString(Expr $value, bool $data, Scope $scope): array + { + $type = $scope->getType($value); + + $strings = $type->getConstantStrings(); + + if ($type->isString()->yes() && $strings !== []) { + foreach ($strings as $string) { + $rules = $string->getValue(); + + if (str_contains($rules, '|') && ! ($data && $this->isRegex($rules))) { + // One of several values has no single replacement to name. + return [$this->buildError($value, count($strings) === 1 ? $rules : null)]; + } + } + + return []; + } + + if (! $value instanceof Concat && ! $value instanceof InterpolatedString) { + return []; + } + + $pieces = $this->getPieces($value, $scope); + + if (! array_any($pieces, static fn (?string $piece): bool => $piece !== null && str_contains($piece, '|'))) { + return []; + } + + // Data keeps a string starting with a regex rule whole, so its rule name must be known. + if ($data && ! $this->hasNonRegexName($pieces[0] ?? null)) { + return []; + } + + return [$this->buildError($value, null)]; + } + + /** + * Get the known text of each part of a built string, or null for a part that is not constant. + * + * @return list + */ + private function getPieces(Expr $value, Scope $scope): array + { + $strings = $scope->getType($value)->getConstantStrings(); + + if (count($strings) === 1) { + return [$strings[0]->getValue()]; + } + + return match (true) { + $value instanceof Concat => [...$this->getPieces($value->left, $scope), ...$this->getPieces($value->right, $scope)], + $value instanceof InterpolatedString => array_map( + fn (Expr|InterpolatedStringPart $part): ?string => $part instanceof InterpolatedStringPart + ? $part->value + : $this->getPieces($part, $scope)[0], + $value->parts, + ), + default => [null], + }; + } + + /** + * Determine whether the known start of a rule string shows a rule name that is not a regex. + */ + private function hasNonRegexName(?string $start): bool + { + if ($start === null) { + return false; + } + + $colon = strpos($start, ':'); + $pipe = strpos($start, '|'); + + // A name running into "|" cannot be a regex rule's name. + if ($pipe !== false && ($colon === false || $pipe < $colon)) { + return true; + } + + return $colon !== false && ! $this->isRegex($start); + } + + /** + * Determine whether a rule string's name is a regex rule, as ValidationRuleParser::ruleIsRegex() matches it. + */ + private function isRegex(string $rule): bool + { + return in_array(strtolower(explode(':', $rule, 2)[0]), ['regex', 'not_regex', 'notregex'], true); + } + + /** + * Build the error for split rules, with the array to use when the rules are known and splitting them is unambiguous. + */ + private function buildError(Expr $value, ?string $rules): IdentifierRuleError + { + $segments = $rules === null ? [] : explode('|', $rules); + // A regex is split at its own "|", so where it ends is unknown. + $regex = array_any($segments, $this->isRegex(...)); + $unambiguous = $rules !== null + && ! $regex + && ! array_any($segments, static fn (string $segment): bool => $segment === '' || $segment !== trim($segment)); + + $error = RuleErrorBuilder::message(match (true) { + $rules === null => 'Validation rules are pipe-separated; list them in an array.', + $unambiguous => sprintf( + 'Validation rules %s are pipe-separated; list them in an array: [%s].', + var_export($rules, true), + implode(', ', array_map(static fn (string $segment): string => var_export($segment, true), $segments)), + ), + default => sprintf('Validation rules %s are pipe-separated; list them in an array.', var_export($rules, true)), + }) + ->identifier('hypervel.pipeSeparatedValidationRules') + ->line($value->getStartLine()); + + if ($regex) { + $error->addTip('A regex containing "|" must be its own array element.'); + } + + if ($unambiguous && $value instanceof String_) { + $error->fixNode($value, static function (String_ $string): Array_ { + // The fixer passes its own copy of the string; the new elements keep its quoting. + $attributes = in_array($string->getAttribute('kind'), [String_::KIND_SINGLE_QUOTED, String_::KIND_DOUBLE_QUOTED], true) + ? ['kind' => $string->getAttribute('kind')] + : []; + + return new Array_( + array_map(static fn (string $segment): ArrayItem => new ArrayItem(new String_($segment, $attributes)), explode('|', $string->value)), + ['kind' => Array_::KIND_SHORT], + ); + }); + } + + return $error->build(); + } +} diff --git a/src/phpstan/src/Rules/ViewStringFileDependencyRule.php b/src/phpstan/src/Rules/ViewStringFileDependencyRule.php new file mode 100644 index 0000000000..4b2d1bc849 --- /dev/null +++ b/src/phpstan/src/Rules/ViewStringFileDependencyRule.php @@ -0,0 +1,95 @@ + + */ +#[AutowiredService] +class ViewStringFileDependencyRule implements Rule +{ + /** + * The pattern matching view-string and the global type aliases that resolve to one, built on first use. + */ + private ?string $pattern = null; + + /** + * Create a new view-string file dependency rule. + * + * @param array $typeAliases the global type aliases + */ + public function __construct( + private RuntimeInputs $runtimeInputs, + private TypeStringResolver $typeStringResolver, + #[AutowiredParameter] + private array $typeAliases, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return FileNode::class; + } + + /** + * Declare the dependency of a file whose text names a view-string type. + * + * @param FileNode $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (preg_match($this->pattern ??= $this->createPattern(), (string) file_get_contents($scope->getFile())) === 1) { + $this->runtimeInputs->trackViews($scope); + } + + return []; + } + + /** + * Create the pattern matching view-string and the global type aliases that resolve to one. + */ + private function createPattern(): string + { + $names = ['view-string']; + + foreach ($this->typeAliases as $alias => $definition) { + if (ViewStringType::occursIn($this->typeStringResolver->resolve($definition))) { + $names[] = preg_quote($alias, '/'); + } + } + + return '/\b(?:' . implode('|', $names) . ')\b/'; + } +} diff --git a/src/phpstan/src/Rules/ViewStringFunctionDependencyRule.php b/src/phpstan/src/Rules/ViewStringFunctionDependencyRule.php new file mode 100644 index 0000000000..16f1b6ab40 --- /dev/null +++ b/src/phpstan/src/Rules/ViewStringFunctionDependencyRule.php @@ -0,0 +1,105 @@ + + */ +#[AutowiredService] +class ViewStringFunctionDependencyRule implements Rule +{ + /** + * Whether each function's signature uses a view-string, by lowercased name. + * + * @var array + */ + private array $functions = []; + + /** + * Create a new view-string function dependency rule. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * Declare the dependency of a call to a function whose signature uses a view-string. + * + * @param FuncCall $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Name || ! $this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $function = $this->reflectionProvider->getFunction($node->name, $scope); + + if ($this->functions[strtolower($function->getName())] ??= $this->usesViewString($function)) { + $this->runtimeInputs->trackViews($scope); + } + + return []; + } + + /** + * Determine whether a function's parameter, return or assertion types use a view-string. + */ + private function usesViewString(FunctionReflection $function): bool + { + foreach ($function->getVariants() as $variant) { + if (ViewStringType::occursIn($variant->getReturnType())) { + return true; + } + + foreach ($variant->getParameters() as $parameter) { + if (ViewStringType::occursIn($parameter->getType())) { + return true; + } + } + } + + foreach ($function->getAsserts()->getAll() as $assert) { + if (ViewStringType::occursIn($assert->getType())) { + return true; + } + } + + return false; + } +} diff --git a/src/phpstan/src/RuntimeInputsResultCacheValueExtension.php b/src/phpstan/src/RuntimeInputsResultCacheValueExtension.php new file mode 100644 index 0000000000..7912932634 --- /dev/null +++ b/src/phpstan/src/RuntimeInputsResultCacheValueExtension.php @@ -0,0 +1,288 @@ + + */ + private array $values = []; + + /** + * Create a new extension instance. + */ + public function __construct( + #[AutowiredParameter(ref: '%currentWorkingDirectory%')] + private string $workingDirectory, + ) { + } + + /** + * Create the key for the given inputs. + * + * @param list $files files compared by content + * @param list $presenceFiles files compared only by whether they exist + * @param array $directories directories mapped to the fnmatch() pattern of the files compared in them, recursively + * @param array $linkedDirectories directories compared as $directories are, also through their symlinked directories + * @param list $listings directories compared by the names of the files in them, recursively, also through their symlinked directories + * @param list $variables environment variable names + * @param list $variablePatterns fnmatch() patterns of environment variable names, matched when compared + */ + public function createKey( + array $files = [], + array $presenceFiles = [], + array $directories = [], + array $linkedDirectories = [], + array $listings = [], + array $variables = [], + array $variablePatterns = [], + ): string { + $lines = []; + + foreach ([ + self::FILE => array_map($this->relativePath(...), $files), + self::PRESENCE => array_map($this->relativePath(...), $presenceFiles), + self::DIRECTORY => $this->directoryEntries($directories), + self::LINKED_DIRECTORY => $this->directoryEntries($linkedDirectories), + self::LISTING => array_map($this->relativePath(...), $listings), + self::VARIABLE => $variables, + self::VARIABLE_PATTERN => $variablePatterns, + ] as $type => $entries) { + $entries = array_values(array_unique($entries)); + sort($entries); + + foreach ($entries as $entry) { + $lines[] = $type . "\t" . $entry; + } + } + + return implode("\n", $lines); + } + + /** + * Get the digest of a file's contents, or 'missing'. + */ + public static function fileDigest(string $path): string + { + return is_file($path) ? (string) hash_file('xxh128', $path) : 'missing'; + } + + /** + * Get the digest of the names and contents of the files in a directory, recursively, whose names match the pattern. + */ + public static function directoryDigest(string $directory, string $pattern, bool $followLinks = false): string + { + if (! is_dir($directory)) { + return 'missing'; + } + + $lines = []; + + foreach (self::files($directory, $pattern, $followLinks) as $name => $path) { + $lines[] = $name . "\t" . self::fileDigest($path); + } + + return hash('xxh128', implode("\n", $lines)); + } + + /** + * Get the digest of the names of the files in a directory, recursively, or 'missing'. + * + * The view finder finds views through symlinked directories, so the listing follows them. + */ + public static function listingDigest(string $directory): string + { + return is_dir($directory) ? hash('xxh128', implode("\n", array_keys(self::files($directory, '*', followLinks: true)))) : 'missing'; + } + + /** + * Get the current value for the key. + */ + public function getValue(string $key): string + { + $digest = hash('xxh128', $key); + + if (isset($this->values[$digest])) { + return $this->values[$digest]; + } + + $parts = []; + $variables = []; + $variablePatterns = []; + + foreach ($this->entries($key) as [$type, $entry]) { + match ($type) { + self::FILE => $parts[] = $entry . "\t" . self::fileDigest($this->absolutePath($entry)), + self::PRESENCE => $parts[] = $entry . "\t" . (is_file($this->absolutePath($entry)) ? 'present' : 'missing'), + self::DIRECTORY, self::LINKED_DIRECTORY => $parts[] = $entry . "\t" . $this->directoryEntryDigest($entry, $type === self::LINKED_DIRECTORY), + self::LISTING => $parts[] = $entry . "\t" . self::listingDigest($this->absolutePath($entry)), + self::VARIABLE => $variables[] = $entry, + self::VARIABLE_PATTERN => $variablePatterns[] = $entry, + }; + } + + $parts[] = EnvironmentSnapshot::digest($variables, $variablePatterns); + + return $this->values[$digest] = hash('xxh128', implode("\n", $parts)); + } + + /** + * Get the key as the result cache stores it; its paths are already relative to the working directory. + */ + public function keyToResultCache(string $key): string + { + return $key; + } + + /** + * Get the key back from the result cache. + */ + public function keyFromResultCache(string $storedKey): string + { + return $storedKey; + } + + /** + * Get the files in a directory, recursively, whose names match the pattern, by their path relative to it, sorted. + * + * @return array + */ + private static function files(string $directory, string $pattern, bool $followLinks = false): array + { + $files = []; + $directories = $followLinks + ? RecursiveDirectoryIterator::followingLinks($directory) + : new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS); + + foreach (new RecursiveIteratorIterator($directories) as $file) { + if ($file instanceof SplFileInfo && $file->isFile() && fnmatch($pattern, $file->getFilename())) { + $files[substr($file->getPathname(), strlen($directory) + 1)] = $file->getPathname(); + } + } + + ksort($files); + + return $files; + } + + /** + * Get the digest of a directory entry, which holds the directory and its pattern. + */ + private function directoryEntryDigest(string $entry, bool $followLinks): string + { + // The pattern follows the directory, so resolving the entry resolves the directory. + [$directory, $pattern] = explode("\t", $this->absolutePath($entry), 2); + + return self::directoryDigest($directory, $pattern, $followLinks); + } + + /** + * Get the key entries of directories mapped to their patterns. + * + * @param array $directories + * @return list + */ + private function directoryEntries(array $directories): array + { + $entries = []; + + foreach ($directories as $directory => $pattern) { + $entries[] = $this->relativePath($directory) . "\t" . $pattern; + } + + return $entries; + } + + /** + * Get a path relative to the working directory, or unchanged when it is outside it. + */ + private function relativePath(string $path): string + { + return str_starts_with($path, $this->workingDirectory . '/') ? substr($path, strlen($this->workingDirectory) + 1) : $path; + } + + /** + * Get the absolute path of a key path. + */ + private function absolutePath(string $path): string + { + return str_starts_with($path, '/') ? $path : $this->workingDirectory . '/' . $path; + } + + /** + * Get the typed entries of a key. + * + * @return list + */ + private function entries(string $key): array + { + if ($key === '') { + return []; + } + + $entries = []; + + foreach (explode("\n", $key) as $line) { + /** @var array{self::DIRECTORY|self::FILE|self::LINKED_DIRECTORY|self::LISTING|self::PRESENCE|self::VARIABLE|self::VARIABLE_PATTERN, string} $entry createKey() writes each line as a type and an entry */ + $entry = explode("\t", $line, 2); + $entries[] = $entry; + } + + return $entries; + } +} diff --git a/src/phpstan/src/Support/ArrKeyLookup.php b/src/phpstan/src/Support/ArrKeyLookup.php new file mode 100644 index 0000000000..b214bf4e86 --- /dev/null +++ b/src/phpstan/src/Support/ArrKeyLookup.php @@ -0,0 +1,129 @@ + $methods + */ + public static function appliesTo(Scope $scope, StaticCall $call, array $methods): bool + { + if (! $call->class instanceof Name) { + return false; + } + + $calledType = $scope->resolveTypeByName($call->class); + + foreach ($methods as $method) { + if ($scope->getMethodReflection($calledType, $method)?->getDeclaringClass()->getName() !== Arr::class) { + return false; + } + } + + return true; + } + + /** + * Get, for each possible key, the lookups Arr::get() may take: the literal key, then its + * "dot" path unless the literal key always exists. Each lookup is whether it finds a value and + * the access that reads it, or the first missing access. + * + * @return null|list> null when the key isn't + * an integer or constant string + */ + public static function find(Scope $scope, Expr $array, Expr $key): ?array + { + $keyType = $scope->getType($key); + + if ($keyType->isInteger()->yes()) { + return [[self::lookup($scope, $array, [$key])]]; + } + + $constantStrings = $keyType->getConstantStrings(); + + if ($constantStrings === []) { + return null; + } + + $results = []; + + foreach ($constantStrings as $constantString) { + $value = $constantString->getValue(); + $literal = self::lookup($scope, $array, [new String_($value)]); + + $results[] = $literal[0]->yes() || ! str_contains($value, '.') + ? [$literal] + : [$literal, self::lookup($scope, $array, array_map(static fn ($segment) => new String_($segment), explode('.', $value)))]; + } + + return $results; + } + + /** + * Follow the keys through the array, stopping at the first one that never exists. + * + * @param non-empty-list $keys + * + * @return array{TrinaryLogic, ArrayDimFetch} + */ + private static function lookup(Scope $scope, Expr $array, array $keys): array + { + $exists = TrinaryLogic::createYes(); + $value = $array; + + foreach ($keys as $key) { + $exists = $exists->and(self::keyExists($scope->getType($value), $scope->getType($key))); + $value = new ArrayDimFetch($value, $key); + + if ($exists->no()) { + break; + } + } + + return [$exists, $value]; + } + + /** + * Determine whether Arr::get() finds the key in a value of the given type. + * + * Arr::get() only reads arrays and ArrayAccess objects, never string offsets. Arrays cast + * numeric-string keys like '0' to integers; offsetExists() receives them unchanged. + */ + private static function keyExists(Type $type, Type $keyType): TrinaryLogic + { + return $type->isArray()->and($type->hasOffsetValueType($keyType->toArrayKey())) + ->or((new ObjectType(ArrayAccess::class))->isSuperTypeOf($type)->result->and($type->hasOffsetValueType($keyType))); + } +} diff --git a/src/phpstan/src/Support/BladeScanner.php b/src/phpstan/src/Support/BladeScanner.php new file mode 100644 index 0000000000..fdff620e5b --- /dev/null +++ b/src/phpstan/src/Support/BladeScanner.php @@ -0,0 +1,198 @@ +, list} the content and offset of each remaining verbatim block, then of each remaining @php block + */ + public function setAside(string &$source): array + { + $verbatimBlocks = $this->take(self::VERBATIM_REGEX, $source); + $phpBlocks = $this->take(self::PHP_BLOCK_REGEX, $source); + $comments = $this->take(self::COMMENT_REGEX, $source); + + $commented = static fn (int $offset): bool => array_any( + $comments, + static fn (array $comment): bool => $offset >= $comment[0][1] && $offset < $comment[0][1] + strlen($comment[0][0]), + ); + + $remaining = [[], []]; + + foreach ([$verbatimBlocks, $phpBlocks] as $index => $blocks) { + foreach ($blocks as $match) { + if (! $commented($match[1][1])) { + $remaining[$index][] = $match[1]; + } + } + } + + return $remaining; + } + + /** + * Get the text outside a view's PHP tags, which is what Blade compiles, with each block's line. + * + * Blade splits the view with PHP's tokenizer, so a view whose PHP only becomes valid once + * Blade compiles it, such as native PHP opening a block that @endif closes, still has text. + * + * @return list + */ + public function text(string $source): array + { + $blocks = []; + + foreach (token_get_all($source) as $token) { + if (is_array($token) && $token[0] === T_INLINE_HTML) { + $blocks[] = [$token[1], $token[2]]; + } + } + + return $blocks; + } + + /** + * Get the directives with arguments that Blade compiles in the given text, with each one's argument list and its offset. + * + * Blade leaves an unknown directive as text, and a custom directive's handler decides + * what its arguments mean, so neither is included. + * + * @return list + */ + public function directives(string $html): array + { + /** @var BladeCompiler $compiler */ + $compiler = $this->resolve('blade.compiler'); + + preg_match_all(self::DIRECTIVE_REGEX, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + $directives = []; + $end = 0; + + foreach ($matches as $match) { + // Blade reads a directive inside another's arguments as part of those arguments. + if ($match[0][1] < $end) { + continue; + } + + $start = $match[0][1] + strlen($match[0][0]); + $arguments = $this->argumentList($html, $start); + + if ($arguments === null) { + continue; + } + + $end = $start + strlen($arguments); + $name = $match[1][0]; + + if (! isset($compiler->getCustomDirectives()[$name]) && method_exists($compiler, 'compile' . ucfirst($name))) { + $directives[] = [$name, $arguments, $start]; + } + } + + return $directives; + } + + /** + * Take the matches of the given pattern out of the view text, keeping every offset and line. + * + * @return list> + */ + public function take(string $pattern, string &$html): array + { + preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL); + + $html = (string) preg_replace_callback( + $pattern, + static fn (array $match): string => (string) preg_replace('/[^\n]/', ' ', $match[0]), + $html, + ); + + return $matches; + } + + /** + * Get the parenthesized argument list starting at the given offset of the view text. + */ + private function argumentList(string $html, int $offset): ?string + { + // Try each closing parenthesis in turn, so only the call itself is tokenized. + // PHP's tokenizer keeps parentheses inside strings and comments out of the count. + for ($end = strpos($html, ')', $offset); $end !== false; $end = strpos($html, ')', $end + 1)) { + $arguments = substr($html, $offset, $end + 1 - $offset); + $depth = 0; + + foreach (array_slice(token_get_all('output ?? new ConsoleOutput; + $errorOutput = $output instanceof ConsoleOutput ? $output->getErrorOutput() : $output; + + if ($this->decorated !== null) { + $output->setDecorated($this->decorated); + $errorOutput->setDecorated($this->decorated); + } + + $decorated = $errorOutput->isDecorated(); + + if ($decorated && $this->renderWithCollision($throwable, $errorOutput)) { + return; + } + + $this->renderWithSymfonyStyle($throwable, $errorOutput); + } + + /** + * Render the failure with Collision when it is installed. + */ + private function renderWithCollision(Throwable $throwable, OutputInterface $output): bool + { + if (! class_exists(Provider::class) || ! function_exists('Termwind\render')) { + return false; + } + + try { + $message = $this->formatError($throwable, includeErrorDetails: false); + $title = self::TITLE; + + $body = $this->buildCollisionBody($message); + + renderUsing($output); + + render(<< +
+ {$title} +
+
+ {$body} +
+
Exception Details
+
+ + + HTML); + + $provider = new Provider; + $handler = $provider->register() + ->getHandler() + ->setOutput($output); + + $inspector = match (true) { + class_exists(Inspector::class) => new Inspector($throwable), + class_exists(InspectorFactory::class) => (new InspectorFactory)->create($throwable), + default => null, + }; + + if ($inspector !== null) { + $handler->setInspector($inspector); + } + + $handler->handle($throwable); + + return true; + } catch (Throwable) { + return false; + } + } + + /** + * Render the failure with Symfony console styling. + */ + private function renderWithSymfonyStyle(Throwable $throwable, OutputInterface $output): void + { + $message = $this->formatError($throwable, decorated: $output->isDecorated()); + + $io = new SymfonyStyle(new ArrayInput([]), $output); + + $io->block(self::TITLE, null, 'fg=white;bg=red', ' ', true); + + foreach ($this->splitLines($message) as $line) { + // Exception messages and traces are literal text, not console markup. + $line = OutputFormatter::escape($line); + + match (true) { + $line === '' => $io->newLine(), + str_starts_with($line, 'Error:') => $io->writeln('' . $line . ''), + str_starts_with($line, 'Stack trace:') => $io->writeln('' . $line . ''), + preg_match('/^#\d+/', $line) === 1 => $io->writeln('' . $line . ''), + default => $io->writeln($line), + }; + } + } + + /** + * Format a bootstrap failure. + * + * Upstream blames the framework for failures under a /vendor/ path, which misreports custom + * vendor directories and path repositories, so every failure gets the same guidance. + */ + private function formatError(Throwable $throwable, bool $includeErrorDetails = true, bool $decorated = true): string + { + $parts = [ + 'PHPStan was unable to bootstrap your application.', + '', + 'Hypervel PHPStan boots your Hypervel application during analysis to provide smarter results.', + 'The application reported an error while booting, so the analysis could not continue.', + '', + sprintf('%s Try the following:', $this->getTipSymbol($decorated)), + ' - Check your environment variables in the .env file', + ' - Check the Hypervel logs in storage/logs/hypervel.log for more details', + ' - Run composer dump-autoload to ensure classes are indexed', + ' - Verify service provider registration and configuration', + ' - Verify the application boots by running php artisan about, or vendor/bin/testbench about for a package', + ]; + + return $this->appendErrorDetails($parts, $throwable, $includeErrorDetails); + } + + /** + * Append the error message and stack trace when requested. + * + * @param list $parts + */ + private function appendErrorDetails(array $parts, Throwable $throwable, bool $includeErrorDetails): string + { + if ($includeErrorDetails) { + $parts = array_merge($parts, [ + '', + sprintf('Error: %s', $this->formatErrorMessage($throwable)), + '', + 'Stack trace:', + $this->formatStackTrace($throwable), + ]); + } + + return implode(PHP_EOL, $parts); + } + + /** + * Format the exception message. + */ + private function formatErrorMessage(Throwable $throwable): string + { + $message = trim($throwable->getMessage()); + + return $message !== '' ? $message : sprintf('Unhandled %s with no message', $throwable::class); + } + + /** + * Format the stack trace relative to the project root. + */ + private function formatStackTrace(Throwable $throwable): string + { + $projectRoot = getcwd(); + $trace = $throwable->getTraceAsString(); + + // Upstream converts every backslash to normalize Windows paths, which also + // rewrites namespace separators. Swoole has no native Windows support, so + // paths already use forward slashes. + return $projectRoot !== false + ? str_replace($projectRoot . '/', '', $trace) + : $trace; + } + + /** + * Split a message into lines. + * + * @return array + */ + private function splitLines(string $message): array + { + return preg_split('/\r\n|\r|\n/', $message) ?: []; + } + + /** + * Build the Termwind markup for a message. + */ + private function buildCollisionBody(string $message): string + { + return implode('', array_map(fn (string $line): string => match (true) { + $line === '' => '
', + str_starts_with($line, ' - ') => '
• ' . $this->escapeForHtml(substr($line, 3)) . '
', + default => '
' . $this->escapeForHtml($line) . '
', + }, $this->splitLines($message))); + } + + /** + * Escape a value for Termwind markup. + */ + private function escapeForHtml(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } + + /** + * Return the tip prefix for the output mode. + */ + private function getTipSymbol(bool $decorated): string + { + return $decorated ? "\u{1F4A1}" : 'Tip:'; + } +} diff --git a/src/phpstan/src/Support/CallStack.php b/src/phpstan/src/Support/CallStack.php new file mode 100644 index 0000000000..3e063c2930 --- /dev/null +++ b/src/phpstan/src/Support/CallStack.php @@ -0,0 +1,36 @@ + + */ + public static function enclosing(Scope $scope): array + { + $calls = []; + + // @TODO PHPStan's invalidateExpression() and invalidateMethodsOnExpression() discard the call stack, + // so a callback loses its enclosing calls after a statement that invalidates an expression. A closure's + // parent scope is where it was created and keeps them. Remove this recovery once the minimum supported + // PHPStan version preserves the stack in both methods. + for ($level = $scope; $level !== null; $level = $level->isInAnonymousFunction() ? $level->getParentScope() : null) { + array_push($calls, ...array_reverse($level->getFunctionCallStack())); + } + + return $calls; + } +} diff --git a/src/phpstan/src/Support/CollectionHelper.php b/src/phpstan/src/Support/CollectionHelper.php new file mode 100644 index 0000000000..38bacd0320 --- /dev/null +++ b/src/phpstan/src/Support/CollectionHelper.php @@ -0,0 +1,302 @@ + + */ + private array $collectionTypes = []; + + /** + * Create a new collection helper instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private DeclarationDependencyTracker $declarationDependencyTracker, + private AttributeClassNameResolver $attributeClassNames, + ) { + } + + /** + * Determine whether the class runs a framework collection's own implementation of each + * method, which a subclass may override. + * + * @param list $methods + */ + public static function usesFrameworkMethods(ClassReflection $classReflection, array $methods): bool + { + foreach ($methods as $method) { + if (! $classReflection->hasNativeMethod($method) + || ! in_array($classReflection->getNativeMethod($method)->getDeclaringClass()->getName(), self::FRAMEWORK_COLLECTIONS, true)) { + return false; + } + } + + return true; + } + + /** + * Determine whether a framework builder or relation implements the method, so the model's newCollection() + * creates the collections it returns. A custom builder's or relation's override keeps its declaration. + */ + public static function isFrameworkQueryMethod(MethodReflection $methodReflection): bool + { + $prototype = $methodReflection->getPrototype(); + + // A relation runs the builder method it forwards. + if ($prototype instanceof ForwardedMethodReflection) { + $methodReflection = $prototype->getForwardedMethod(); + } + + $declaringClass = $methodReflection->getDeclaringClass(); + + return $declaringClass->hasNativeMethod($methodReflection->getName()) + && ($declaringClass->getName() === EloquentBuilder::class + || str_starts_with($declaringClass->getName(), Str::beforeLast(Relation::class, '\\') . '\\')); + } + + /** + * Get the collection type that collect() makes from a scalar, null or an empty array. + * + * collect()'s declared signature already describes objects and other arrays. Upstream + * also typed objects here, casting Arrayable objects with (array) instead of reading + * toArray(), and forcing integer keys onto Eloquent collections. + */ + public function determineGenericCollectionTypeFromType(Type $type): ?GenericObjectType + { + if (! $type->isObject()->no()) { + return null; + } + + // Matches Arr::wrap(), which collect() uses for scalars and null. + $arrayType = $type->toArray(); + + if ($arrayType->isIterableAtLeastOnce()->no()) { + return new GenericObjectType(Collection::class, [new BenevolentUnionType([new IntegerType, new StringType]), new MixedType]); + } + + if ($type->isArray()->yes()) { + return null; + } + + return new GenericObjectType(Collection::class, [$arrayType->getIterableKeyType(), $arrayType->getIterableValueType()]); + } + + /** + * Get the collection type the model's `newCollection()` creates: the class its `CollectedBy` + * attribute or static $collectionClass names, or the method's declared return, which can be + * a union or an intersection. + */ + private function determineCollectionType(string $modelClassName): Type + { + try { + $modelReflection = $this->reflectionProvider->getClass($modelClassName); + $newCollectionMethod = $modelReflection->getNativeMethod('newCollection'); + + // The attribute and $collectionClass are read by HasCollection::newCollection(), so they have no effect + // when that method is overridden. + if ($this->usesFrameworkNewCollection($modelReflection)) { + $collectionClassName = $this->findCollectedByClassName($modelReflection) ?? $this->findCollectionClassDefault($modelReflection); + + if ($collectionClassName !== null) { + return new ObjectType($collectionClassName); + } + } + + return $newCollectionMethod->getVariants()[0]->getReturnType(); + } catch (MissingMethodFromReflectionException|ShouldNotHappenException) { + return new ObjectType(EloquentCollection::class); + } + } + + /** + * Find the collection class of the nearest `CollectedBy` attribute, as + * HasCollection::resolveCollectionFromAttribute() does. + */ + private function findCollectedByClassName(ClassReflection $modelReflection): ?string + { + foreach ([$modelReflection, ...$modelReflection->getParents()] as $classReflection) { + $attributes = $classReflection->getNativeReflection()->getAttributes(CollectedBy::class); + + if ($attributes === []) { + continue; + } + + // The attribute is instantiated, so its argument may be named. + $arguments = $attributes[0]->getArgumentsExpressions(); + + return $this->attributeClassNames->resolve($arguments['collectionClass'] ?? $arguments[0] ?? null, $classReflection, $modelReflection); + } + + return null; + } + + /** + * Find the collection class a static $collectionClass default below Model names, which + * HasCollection::newCollection() uses without a `CollectedBy` attribute. + */ + private function findCollectionClassDefault(ClassReflection $modelReflection): ?string + { + // A property default is not part of the model's declarations. + foreach (SourceFiles::forInstance($this->reflectionProvider, $modelReflection, Model::class) as $file) { + $this->declarationDependencyTracker->trackFileDependency($modelReflection, $file); + } + + $nativeReflection = $modelReflection->getNativeReflection(); + + if ($nativeReflection->getProperty('collectionClass')->getDeclaringClass()->getName() === Model::class) { + return null; + } + + $collectionClassName = $nativeReflection->getDefaultProperties()['collectionClass'] ?? null; + + return is_string($collectionClassName) && $this->reflectionProvider->hasClass($collectionClassName) + ? $collectionClassName + : null; + } + + /** + * Determine whether the model creates its collections with HasCollection::newCollection(), which uses the + * `CollectedBy` attribute or the static $collectionClass. + */ + public function usesFrameworkNewCollection(ClassReflection $modelReflection): bool + { + return $modelReflection->getNativeReflection()->getMethod('newCollection')->getFileName() + === $this->reflectionProvider->getClass(HasCollection::class)->getFileName(); + } + + /** + * Get the collection type of the model, with the model and key types filled in. + */ + public function determineCollectionClass(string $modelClassName, ?Type $modelType = null, ?Type $keyType = null): Type + { + $modelType ??= new ObjectType($modelClassName); + + $this->collectionTypes[$modelClassName] ??= $this->determineCollectionType($modelClassName); + + return TypeTraverser::map($this->collectionTypes[$modelClassName], function (Type $type, callable $traverse) use ($modelType, $keyType): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + // Members that are not collections keep their declared type arguments. + if (! (new ObjectType(Enumerable::class))->isSuperTypeOf($type)->yes()) { + return TypeTraverser::map($type, static fn (Type $type, callable $traverse): Type => $type instanceof StaticType ? $modelType : $traverse($type)); + } + + $classNames = $type->getObjectClassNames(); + + if (count($classNames) !== 1) { + return $type; + } + + $collectionClassName = $classNames[0]; + $collectionReflection = $this->reflectionProvider->getClass($collectionClassName); + + if ($collectionReflection->isGeneric()) { + $typeMap = $collectionReflection->getActiveTemplateTypeMap(); + + // Specifies key and value + if ($typeMap->count() === 2) { + return new GenericObjectType($collectionClassName, [$keyType ?? new IntegerType, $modelType]); + } + + // Specifies only value + if (($typeMap->count() === 1) && $typeMap->hasType('TModel')) { + return new GenericObjectType($collectionClassName, [$modelType]); + } + } + + // Not generic. So return the type as is + return new ObjectType($collectionClassName); + }); + } + + /** + * Replace each Eloquent collection in the type with its model's collection type. + */ + public function replaceCollectionsInType(Type $type): Type + { + if (! in_array(EloquentCollection::class, $type->getReferencedClasses(), true)) { + return $type; + } + + return TypeTraverser::map($type, function ($type, $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if (! (new ObjectType(EloquentCollection::class))->isSuperTypeOf($type)->yes()) { + return $traverse($type); + } + + $modelType = $type->getTemplateType(EloquentCollection::class, 'TModel'); + $models = $modelType->getObjectClassNames(); + + if (count($models) === 0) { + return $type; + } + + $keyType = $type->getTemplateType(EloquentCollection::class, 'TKey'); + + // Unlike upstream, each model keeps the rest of its value type, such as a pivot shape, and + // the collection keeps its key type. + return TypeCombinator::union(...array_map( + fn (string $model): Type => $this->determineCollectionClass($model, TypeCombinator::intersect($modelType, new ObjectType($model)), $keyType), + $models, + )); + }); + } +} diff --git a/src/phpstan/src/Support/ConfigArray.php b/src/phpstan/src/Support/ConfigArray.php new file mode 100644 index 0000000000..8bd3fc7266 --- /dev/null +++ b/src/phpstan/src/Support/ConfigArray.php @@ -0,0 +1,32 @@ + $entries + * @param array $optionalKeys the keys that may be absent + * @param bool $open whether keys besides the entries may be present + */ + public function __construct( + public array $entries = [], + public array $optionalKeys = [], + public bool $open = false, + ) { + } +} diff --git a/src/phpstan/src/Support/ConfigParser.php b/src/phpstan/src/Support/ConfigParser.php new file mode 100644 index 0000000000..533d69e11d --- /dev/null +++ b/src/phpstan/src/Support/ConfigParser.php @@ -0,0 +1,1314 @@ + + */ + private ?array $configFiles = null; + + /** + * Key prefixes with config files below them, whose values Hypervel merges + * from several files. + * + * @var array + */ + private array $mergedConfigKeys = []; + + /** + * The framework config files merged beneath the application's, keyed by name. + * + * @var array + */ + private array $frameworkConfigFiles = []; + + /** + * Each config key's presence and value type, or null when its value is unknown. A read + * while an application config file loads is keyed by that file's key, a NUL byte and the key. + * + * @var array + */ + private array $parsedConfigs = []; + + /** + * Each top-level key's composed value, held in an otherwise empty root array, keyed as + * the parsed values are. + * + * @var array + */ + private array $composedConfigs = []; + + /** + * Each config file's return statement, the statements before it and its namespace, + * or null when the file has no single return value. + * + * @var array, null|string}> + */ + private array $parsedConfigFiles = []; + + /** + * The scope at each config file's return statement, or null when the + * statements before it always terminate. + * + * @var array + */ + private array $returnScopes = []; + + /** + * Files and directories the statements before a config file's return depend on, + * such as the config files they read, each path mapped to whether it is a directory. + * + * @var array> + */ + private array $configFileDependencies = []; + + /** + * Files and directories a config key's value depends on besides the key's own + * sources, such as config files its expression reads. + * + * @var array> + */ + private array $keyDependencies = []; + + /** + * Files and directories each top-level key's composed value depends on. + * + * @var array> + */ + private array $compositionDependencies = []; + + /** + * The config file each value expression is in, keyed by the expression's object ID. + * + * @var array + */ + private array $expressionFiles = []; + + /** + * Each value expression's type, or null when unknown, keyed by its object ID. + * + * @var array + */ + private array $expressionTypes = []; + + /** + * Files and directories each value expression's type depends on, keyed by its object ID. + * + * @var array> + */ + private array $expressionDependencies = []; + + /** + * Dependencies collected for the config files and keys being typed, so + * a cached result can declare them again to every file reading it. + * + * @var list> + */ + private array $dependencyCollectors = []; + + /** + * The paths whose contents can change each config key's value, each mapped to whether it is a directory. + * + * @var array> + */ + private array $keySources = []; + + /** + * How many lookups found their config file, expression or composed value already + * being typed further up the call stack. Results typed meanwhile are not cached. + */ + private int $inProgressLookups = 0; + + /** + * Create a new parser instance. + */ + public function __construct( + private FileHelper $fileHelper, + #[AutowiredParameter(ref: '@currentPhpVersionRichParser')] + private Parser $parser, + private FileTypeMapper $fileTypeMapper, + private ScopeFactory $scopeFactory, + private NodeScopeResolver $nodeScopeResolver, + private ConfigSourceResolver $configSourceResolver, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get whether a config key is present and its value's type, or null when unknown. + * + * The presence decides whether a lookup's default can apply: a present key returns + * its value even when it is null, and an absent one returns the default. + * + * @param DependencyTracker&Scope $scope + * @return null|array{TrinaryLogic, Type} + */ + public function getValue(string $key, Scope $scope): ?array + { + // The booted providers decide the config directories, merges and writes. + $this->runtimeInputs->track($scope); + + // Every analyzed file reading the key depends on its sources, parsed or served from the cache. + $this->trackDependencies($scope, $this->keySources[$key] ??= $this->configSourceResolver->getKeySources( + $key, + $this->configSourceResolver->getConfigDirectories(), + )); + $this->loadConfigFiles(); + + // A config file reads the configuration as it stands while the file loads. Framework + // files load before any value is set, and providers merge package files in an order + // the analysis does not follow, so only an application file's reads are typed. + $file = $this->fileHelper->normalizePath($scope->getFile()); + $loadingKey = array_search($file, $this->configFiles ?? [], true); + + if ($loadingKey === false && (in_array($file, $this->frameworkConfigFiles, true) || $this->configSourceResolver->isPackageConfigFile($file))) { + return null; + } + + // A function or closure body may run while the file loads or long after. + if ($loadingKey !== false && ($scope->getFunction() !== null || $scope->isInAnonymousFunction())) { + return null; + } + + $loadingKey = $loadingKey === false ? null : (string) $loadingKey; + $cacheKey = $loadingKey === null ? $key : $loadingKey . "\0" . $key; + + if (array_key_exists($cacheKey, $this->parsedConfigs)) { + $this->trackDependencies($scope, $this->keyDependencies[$cacheKey] ?? []); + + return $this->parsedConfigs[$cacheKey]; + } + + // Writes run only once every config file has loaded. + if (array_key_exists($key, $this->mergedConfigKeys) || ($loadingKey === null && $this->configSourceResolver->hasUnknownWrites())) { + return null; + } + + $inProgressLookups = $this->inProgressLookups; + $segments = explode('.', $key); + + [$value, $dependencies] = $this->collectDependencies(function () use ($segments, $loadingKey, $scope): ?array { + $root = $this->getComposedConfig($segments[0], $loadingKey, $scope); + + return $root === null ? null : $this->lookup($root, $segments, $scope); + }); + + $this->trackDependencies($scope, $dependencies); + + if ($this->inProgressLookups === $inProgressLookups) { + $this->keyDependencies[$cacheKey] = $dependencies; + $this->parsedConfigs[$cacheKey] = $value; + } + + return $value; + } + + /** + * Walk a composed value to a key, as Arr::get() does. + * + * @param list $segments + * @param DependencyTracker&Scope $scope + * @return null|array{TrinaryLogic, Type} + */ + private function lookup(ConfigArray $root, array $segments, Scope $scope): ?array + { + $presence = TrinaryLogic::createYes(); + $value = $root; + + foreach ($segments as $segment) { + $key = $this->toArrayKey($segment); + + if ($value instanceof Expr) { + $value = $this->getExpressionType($value, $scope); + } + + if ($value instanceof ConfigArray) { + if (! array_key_exists($key, $value->entries)) { + return $value->open ? null : [TrinaryLogic::createNo(), new NeverType]; + } + + if (isset($value->optionalKeys[$key])) { + $presence = $presence->and(TrinaryLogic::createMaybe()); + } + + $value = $value->entries[$key]; + + continue; + } + + if ($value === null) { + return null; + } + + if ($value->isArray()->yes()) { + $offset = $this->getKeyType($key); + $hasOffset = $value->hasOffsetValueType($offset); + + if ($hasOffset->no()) { + return [TrinaryLogic::createNo(), new NeverType]; + } + + $presence = $presence->and($hasOffset); + $value = $value->getOffsetValueType($offset); + + continue; + } + + // Arr::get() stops at a value it cannot index into, and returns the default. + if ($value->isArray()->no() && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($value)->no()) { + return [TrinaryLogic::createNo(), new NeverType]; + } + + return null; + } + + $type = $this->toType($value, $scope); + + return $type === null ? null : [$presence, $type]; + } + + /** + * Get the root array holding a top-level key's composed value, or null while it is + * being composed further up the call stack. + * + * @param DependencyTracker&Scope $scope + */ + private function getComposedConfig(string $name, ?string $loadingKey, Scope $scope): ?ConfigArray + { + $cacheKey = $loadingKey === null ? $name : $loadingKey . "\0" . $name; + + if (array_key_exists($cacheKey, $this->composedConfigs)) { + $this->trackDependencies($scope, $this->compositionDependencies[$cacheKey]); + + return $this->composedConfigs[$cacheKey]; + } + + $inProgressLookups = $this->inProgressLookups; + + [$result, $dependencies] = $this->collectDependencies(fn (): ?array => $this->guard( + 'hypervel.configComposition.' . $cacheKey, + fn (): ConfigArray => $this->composeConfig($name, $loadingKey, $scope), + )); + + if ($result === null) { + return null; + } + + if ($this->inProgressLookups === $inProgressLookups) { + $this->compositionDependencies[$cacheKey] = $dependencies; + $this->composedConfigs[$cacheKey] = $result[0]; + } + + return $result[0]; + } + + /** + * Compose a top-level key's value in the order Hypervel loads it, then widen it for the + * writes and merges that may or may not run. + * + * With a loading key, compose only what is set while that application config file loads: + * the framework entries without a file of their own and the files before it in load order. + * Providers merge and write only once every file has loaded. + * + * @param DependencyTracker&Scope $scope + */ + private function composeConfig(string $name, ?string $loadingKey, Scope $scope): ConfigArray + { + $root = new ConfigArray; + $frameworkFile = $this->frameworkConfigFiles[$name] ?? null; + $applicationFile = $this->configFiles[$name] ?? null; + $mergeableOptions = $this->configSourceResolver->getMergeableOptions($name); + $isLoaded = static fn (string $configKey): bool => $loadingKey === null || strnatcmp($configKey, $loadingKey) < 0; + + // The framework entry is set with the application file, and files nested below come after it. + if ($applicationFile !== null && ! $isLoaded($name)) { + return $root; + } + + if ($frameworkFile !== null) { + $this->trackDependencies($scope, [$frameworkFile => false]); + $root = new ConfigArray([$name => $this->getFileValue($frameworkFile)]); + } + + if ($applicationFile !== null) { + $value = $this->getFileValue($applicationFile); + + $root = new ConfigArray([$name => $frameworkFile === null ? $value : $this->merge( + $root->entries[$name], + $value, + $mergeableOptions, + $scope, + )]); + } + + foreach ($this->configFiles ?? [] as $configKey => $path) { + if (str_starts_with((string) $configKey, $name . '.') && $isLoaded((string) $configKey)) { + $root = $this->setPath($root, explode('.', (string) $configKey), fn (): ConfigArray|Expr|Type|null => $this->getFileValue($path), $scope); + } + } + + if ($loadingKey !== null) { + return $root; + } + + $merges = array_filter( + $this->configSourceResolver->getMerges(), + static fn (array $merge): bool => explode('.', $merge['key'])[0] === $name, + ); + + // The applications using a package replace the values packages merge, so a package's + // analysis widens for them as for a merge that may not run. + $packageApplication = $this->runtimeInputs->isPackageApplication(); + + foreach ($merges as $merge) { + if ($merge['definite'] && $merge['path'] !== null && ! $packageApplication) { + $path = $merge['path']; + $this->trackDependencies($scope, [$path => false]); + + $root = $this->setPath($root, explode('.', $merge['key']), function (ConfigArray|Expr|Type|null $current, TrinaryLogic $exists) use ($merge, $path, $scope): ?ConfigArray { + // The result is the merge or the package's values alone, depending on whether the application set the key. + if ($exists->maybe()) { + return null; + } + + $package = $this->getFileValue($path); + $current = $exists->yes() ? $current : new ConfigArray; + + return $merge['recursive'] + ? $this->replaceRecursive($package, $current, $scope) + : $this->merge($package, $current, $merge['options'], $scope); + }, $scope); + } + } + + // Framework code adds named entries to mergeable options at runtime, such as log channels. + foreach ($mergeableOptions as $option) { + $root = $this->openRegistry($root, [$name, $option], $scope); + } + + foreach ($merges as $merge) { + foreach ($merge['options'] as $option) { + $root = $this->openRegistry($root, [...explode('.', $merge['key']), $option], $scope); + } + + if (! $merge['definite'] || $packageApplication) { + $root = $this->widenForMerge($root, explode('.', $merge['key']), $merge['options'], $merge['recursive'], $scope); + } + } + + foreach ($this->configSourceResolver->getWrittenKeys() as $writtenKey) { + $segments = explode('.', $writtenKey); + + if ($segments[0] === $name) { + $root = $this->widenForWrite($root, $segments, $scope); + } + } + + return $root; + } + + /** + * Update the value at a path as Arr::set() reaches it: a missing or non-array + * intermediate becomes an empty array. + * + * @param list $segments + * @param callable(null|ConfigArray|Expr|Type, TrinaryLogic): (null|ConfigArray|Expr|Type) $update receives the current value and whether it exists + * @param DependencyTracker&Scope $scope + */ + private function setPath(ConfigArray $array, array $segments, callable $update, Scope $scope): ConfigArray + { + $key = $this->toArrayKey($segments[0]); + $exists = array_key_exists($key, $array->entries); + $current = $exists ? $array->entries[$key] : null; + + if (count($segments) === 1) { + // An open array may hold a key it does not list. + $value = $update($current, match (true) { + $exists && ! isset($array->optionalKeys[$key]) => TrinaryLogic::createYes(), + $exists || $array->open => TrinaryLogic::createMaybe(), + default => TrinaryLogic::createNo(), + }); + } else { + // A key an open array does not list may hold an array with other keys. + $child = $exists ? $this->toConfigArray($current, $scope) : new ConfigArray(open: $array->open); + + if ($child === null || isset($array->optionalKeys[$key])) { + // An intermediate that may not be an array keeps its other keys only if it is one. + $child = $this->isArray($current, $scope)->no() ? new ConfigArray : new ConfigArray(open: true); + } + + $value = $this->setPath($child, array_slice($segments, 1), $update, $scope); + } + + $entries = $array->entries; + $entries[$key] = $value; + $optionalKeys = $array->optionalKeys; + unset($optionalKeys[$key]); + + return new ConfigArray($entries, $optionalKeys, $array->open); + } + + /** + * Widen a composed value for a write that may run: values at and below the key become + * unknown, as does the first intermediate that is not known to be an array. + * + * @param list $segments + * @param DependencyTracker&Scope $scope + */ + private function widenForWrite(ConfigArray $array, array $segments, Scope $scope): ConfigArray + { + return $this->widenPath($array, $segments, static fn (): null => null, $scope); + } + + /** + * Widen a composed value for a merge that may run. + * + * array_merge() keeps the known string keys at the key and at the provider's mergeable + * options and adds others, so those arrays become open. It renumbers integer keys, so + * an array with any becomes unknown. array_replace_recursive() opens every level. + * + * @param list $segments + * @param list $options + * @param DependencyTracker&Scope $scope + */ + private function widenForMerge(ConfigArray $array, array $segments, array $options, bool $recursive, Scope $scope): ConfigArray + { + return $this->widenPath($array, $segments, function (ConfigArray|Expr|Type|null $current) use ($options, $recursive, $scope): ?ConfigArray { + $current = $this->toConfigArray($current, $scope); + + if ($current === null) { + return null; + } + + if ($recursive) { + return $this->openRecursively($current, $scope); + } + + if (array_any($current->entries, static fn (mixed $entry, int|string $key): bool => is_int($key))) { + return null; + } + + $entries = $current->entries; + + foreach ($options as $option) { + if (array_key_exists($option, $entries)) { + $optionArray = $this->toConfigArray($entries[$option], $scope); + + $entries[$option] = $optionArray === null || array_any($optionArray->entries, static fn (mixed $entry, int|string $key): bool => is_int($key)) + ? null + : new ConfigArray($optionArray->entries, $optionArray->optionalKeys, true); + } + } + + return new ConfigArray($entries, $current->optionalKeys, true); + }, $scope); + } + + /** + * Replace the existing value at a path for an update that may run. A missing key, or + * an intermediate not known to be an array, becomes unknown instead. + * + * @param list $segments + * @param callable(null|ConfigArray|Expr|Type): (null|ConfigArray|Expr|Type) $update + * @param DependencyTracker&Scope $scope + */ + private function widenPath(ConfigArray $array, array $segments, callable $update, Scope $scope): ConfigArray + { + $key = $this->toArrayKey($segments[0]); + $entries = $array->entries; + $optionalKeys = $array->optionalKeys; + + if (! array_key_exists($key, $entries)) { + if ($array->open) { + return $array; + } + + $entries[$key] = null; + $optionalKeys[$key] = true; + + return new ConfigArray($entries, $optionalKeys); + } + + if (count($segments) === 1) { + $entries[$key] = $update($entries[$key]); + } else { + $child = $this->toConfigArray($entries[$key], $scope); + $entries[$key] = $child === null ? null : $this->widenPath($child, array_slice($segments, 1), $update, $scope); + } + + return new ConfigArray($entries, $optionalKeys, $array->open); + } + + /** + * Open a mergeable option's array, which framework code extends with named entries. + * + * HandleExceptions fills a log channel whose value is falsy, so an entry that may be + * falsy becomes unknown. + * + * @param list $segments + * @param DependencyTracker&Scope $scope + */ + private function openRegistry(ConfigArray $array, array $segments, Scope $scope): ConfigArray + { + $key = $this->toArrayKey($segments[0]); + + if (! array_key_exists($key, $array->entries)) { + return $array; + } + + $entries = $array->entries; + $current = $entries[$key]; + + if (count($segments) > 1) { + $child = $this->toConfigArray($current, $scope); + $entries[$key] = $child === null ? $current : $this->openRegistry($child, array_slice($segments, 1), $scope); + } elseif (($registry = $this->toConfigArray($current, $scope)) !== null) { + $registryEntries = []; + + foreach ($registry->entries as $entryKey => $entry) { + $registryEntries[$entryKey] = $this->isTruthy($entry, $scope) ? $entry : null; + } + + $entries[$key] = new ConfigArray($registryEntries, $registry->optionalKeys, true); + } else { + $entries[$key] = $this->isArray($current, $scope)->no() ? $current : null; + } + + return new ConfigArray($entries, $array->optionalKeys, $array->open); + } + + /** + * Open an array and every array below it; a value that may be an array becomes unknown. + * + * @param DependencyTracker&Scope $scope + */ + private function openRecursively(ConfigArray $array, Scope $scope): ConfigArray + { + $entries = []; + + foreach ($array->entries as $key => $entry) { + $entries[$key] = match (true) { + $entry instanceof ConfigArray => $this->openRecursively($entry, $scope), + $this->isArray($entry, $scope)->no() => $entry, + default => null, + }; + } + + return new ConfigArray($entries, $array->optionalKeys, true); + } + + /** + * Merge two values as array_merge() does, then merge the options both set the same way. + * + * @param list $options + * @param DependencyTracker&Scope $scope + */ + private function merge(ConfigArray|Expr|Type|null $lower, ConfigArray|Expr|Type|null $higher, array $options, Scope $scope): ?ConfigArray + { + $lowerArray = $this->toConfigArray($lower, $scope); + $higherArray = $this->toConfigArray($higher, $scope); + + if ($lowerArray === null || $higherArray === null) { + return null; + } + + $open = $lowerArray->open || $higherArray->open; + $entries = []; + $optionalKeys = []; + + foreach ([$lowerArray, $higherArray] as $array) { + foreach ($array->entries as $key => $entry) { + $optional = isset($array->optionalKeys[$key]); + + if (is_int($key)) { + // array_merge() renumbers integer keys, which an optional or unknown entry would shift. + if ($optional || $open) { + return null; + } + + $entries[] = $entry; + } elseif ($optional && array_key_exists($key, $entries)) { + $entries[$key] = TypeCombinator::union($this->toType($entries[$key], $scope) ?? new MixedType, $this->toType($entry, $scope) ?? new MixedType); + } else { + $entries[$key] = $entry; + + if ($optional) { + $optionalKeys[$key] = true; + } else { + unset($optionalKeys[$key]); + } + } + } + } + + // The higher array's unknown entries may replace any lower entry it does not list. + if ($higherArray->open) { + foreach (array_keys($lowerArray->entries) as $key) { + if (! array_key_exists($key, $higherArray->entries)) { + $entries[$key] = null; + } + } + } + + foreach ($options as $option) { + if (! array_key_exists($option, $lowerArray->entries) || ! array_key_exists($option, $higherArray->entries)) { + continue; + } + + $lowerNull = $this->isNull($lowerArray->entries[$option], $scope); + $higherNull = $this->isNull($higherArray->entries[$option], $scope); + + // The option is merged only when isset() holds on both sides. + if ($lowerNull->yes() || $higherNull->yes()) { + continue; + } + + $entries[$option] = $lowerNull->no() && $higherNull->no() + ? $this->merge($lowerArray->entries[$option], $higherArray->entries[$option], [], $scope) + : null; + } + + return new ConfigArray($entries, $optionalKeys, $open); + } + + /** + * Merge two values as array_replace_recursive() does. + * + * @param DependencyTracker&Scope $scope + */ + private function replaceRecursive(ConfigArray|Expr|Type|null $lower, ConfigArray|Expr|Type|null $higher, Scope $scope): ?ConfigArray + { + $lowerArray = $this->toConfigArray($lower, $scope); + $higherArray = $this->toConfigArray($higher, $scope); + + if ($lowerArray === null || $higherArray === null) { + return null; + } + + $entries = $lowerArray->entries; + $optionalKeys = $lowerArray->optionalKeys; + + foreach ($higherArray->entries as $key => $entry) { + $higherOptional = isset($higherArray->optionalKeys[$key]); + + if (! array_key_exists($key, $entries)) { + $entries[$key] = $entry; + + if ($higherOptional) { + $optionalKeys[$key] = true; + } + + continue; + } + + // Either side may be absent, so the result may be either value or both merged. + if ($higherOptional || isset($optionalKeys[$key])) { + $entries[$key] = null; + + if (! $higherOptional) { + unset($optionalKeys[$key]); + } + + continue; + } + + $bothArrays = $this->isArray($entries[$key], $scope)->and($this->isArray($entry, $scope)); + + $entries[$key] = match (true) { + $bothArrays->yes() => $this->replaceRecursive($entries[$key], $entry, $scope), + $bothArrays->no() => $entry, + default => null, + }; + } + + // The higher array's unknown entries may replace or merge into any lower entry it does not list. + if ($higherArray->open) { + foreach (array_keys($lowerArray->entries) as $key) { + if (! array_key_exists($key, $higherArray->entries)) { + $entries[$key] = null; + } + } + } + + return new ConfigArray($entries, $optionalKeys, $lowerArray->open || $higherArray->open); + } + + /** + * Get a value as a config array when it is one array of known keys. + * + * @param DependencyTracker&Scope $scope + */ + private function toConfigArray(ConfigArray|Expr|Type|null $value, Scope $scope): ?ConfigArray + { + if ($value instanceof Expr) { + $value = $this->getExpressionType($value, $scope); + } + + if (! $value instanceof Type) { + return $value; + } + + $constantArrays = $value->getConstantArrays(); + + if (count($constantArrays) !== 1 || ! $value->isConstantArray()->yes()) { + return null; + } + + $entries = []; + $optionalKeys = []; + $valueTypes = $constantArrays[0]->getValueTypes(); + + foreach ($constantArrays[0]->getKeyTypes() as $index => $keyType) { + $entries[$keyType->getValue()] = $valueTypes[$index]; + + if ($constantArrays[0]->isOptionalKey($index)) { + $optionalKeys[$keyType->getValue()] = true; + } + } + + return new ConfigArray($entries, $optionalKeys, $constantArrays[0]->isUnsealed()->yes()); + } + + /** + * Get the type of a value, or null when it is unknown. + * + * @param DependencyTracker&Scope $scope + */ + private function toType(ConfigArray|Expr|Type|null $value, Scope $scope): ?Type + { + if ($value instanceof Expr) { + return $this->getExpressionType($value, $scope); + } + + if (! $value instanceof ConfigArray) { + return $value; + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($value->entries as $key => $entry) { + $builder->setOffsetValueType($this->getKeyType($key), $this->toType($entry, $scope) ?? new MixedType, isset($value->optionalKeys[$key])); + } + + if ($value->open) { + $builder->makeUnsealed(new UnionType([new IntegerType, new StringType]), new MixedType); + } + + return $builder->getArray(); + } + + /** + * Determine whether a value is an array. + * + * @param DependencyTracker&Scope $scope + */ + private function isArray(ConfigArray|Expr|Type|null $value, Scope $scope): TrinaryLogic + { + if ($value instanceof ConfigArray) { + return TrinaryLogic::createYes(); + } + + return $this->toType($value, $scope)?->isArray() ?? TrinaryLogic::createMaybe(); + } + + /** + * Determine whether a value is null. + * + * @param DependencyTracker&Scope $scope + */ + private function isNull(ConfigArray|Expr|Type|null $value, Scope $scope): TrinaryLogic + { + if ($value instanceof ConfigArray) { + return TrinaryLogic::createNo(); + } + + return $this->toType($value, $scope)?->isNull() ?? TrinaryLogic::createMaybe(); + } + + /** + * Determine whether a value is known to be truthy. + * + * @param DependencyTracker&Scope $scope + */ + private function isTruthy(ConfigArray|Expr|Type|null $value, Scope $scope): bool + { + if ($value instanceof ConfigArray) { + return array_any(array_keys($value->entries), static fn (int|string $key): bool => ! isset($value->optionalKeys[$key])); + } + + return $this->toType($value, $scope)?->toBoolean()->isTrue()->yes() ?? false; + } + + /** + * Get the value a config file returns: an array of its literal keys, its @return + * type, or the expression it returns. Null when the file has no single return value. + */ + private function getFileValue(string $path): ConfigArray|Expr|Type|null + { + if (! array_key_exists($path, $this->parsedConfigFiles)) { + $this->parsedConfigFiles[$path] = $this->parseConfigFile($path); + } + + $parsedConfigFile = $this->parsedConfigFiles[$path]; + + // We could not parse the file or couldn't find the return array + if ($parsedConfigFile === null) { + return null; + } + + [$return] = $parsedConfigFile; + + // Check if we have a type from the docblock. Upstream reads it only when + // treatPhpDocTypesAsCertain is on, but that setting only relaxes always-true + // checks; PHPStan still infers from PHPDoc types when it is off. + $docComment = $return->getDocComment(); + + if ($docComment !== null) { + // Names in the docblock are resolved with the config file's imports + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $path, + null, + null, + null, + $docComment->getText(), + ); + + $returnTag = $resolvedPhpDoc->getReturnTag(); + + if ($returnTag !== null) { + return $returnTag->getType(); + } + } + + return $return->expr === null ? null : $this->toValue($return->expr, $path); + } + + /** + * Get an expression in a config file as a config array when it is an array literal + * with literal keys, so its values are typed only when a lookup reaches them. + */ + private function toValue(Expr $expr, string $path): ConfigArray|Expr + { + if ($expr instanceof Array_) { + $entries = []; + + // PHP normalizes the keys and numbers the items without one, and the last + // item with a key wins, as in the config file. + foreach ($expr->items as $item) { + if ($item->unpack || ($item->key !== null && ! $item->key instanceof String_ && ! $item->key instanceof Int_)) { + $entries = null; + + break; + } + + if ($item->key === null) { + $entries[] = $this->toValue($item->value, $path); + } else { + $entries[$item->key->value] = $this->toValue($item->value, $path); + } + } + + if ($entries !== null) { + return new ConfigArray($entries); + } + } + + $this->expressionFiles[spl_object_id($expr)] = $path; + + return $expr; + } + + /** + * Get the type of an expression a config file returns, in the scope at its return + * statement. Null when unknown. + * + * @param DependencyTracker&Scope $scope + */ + private function getExpressionType(Expr $expr, Scope $scope): ?Type + { + $id = spl_object_id($expr); + + if (array_key_exists($id, $this->expressionTypes)) { + $this->trackDependencies($scope, $this->expressionDependencies[$id]); + + return $this->expressionTypes[$id]; + } + + $inProgressLookups = $this->inProgressLookups; + $path = $this->expressionFiles[$id]; + + [$result, $dependencies] = $this->collectDependencies(fn (): ?array => $this->guard('hypervel.configExpression.' . $id, function () use ($expr, $path, $scope): ?Type { + $returnScope = $this->getReturnScope($path); + + if ($returnScope === null) { + return null; + } + + $this->trackDependencies($scope, $this->configFileDependencies[$path] ?? []); + + // An undefined variable, such as one an included file assigns, is unknown rather than an error + return TypeTraverser::map($returnScope->getType($expr), static function (Type $type, callable $traverse): Type { + if ($type instanceof ErrorType) { + return new MixedType; + } + + return $traverse($type); + }); + })); + + $this->trackDependencies($scope, $dependencies); + + $type = $result[0] ?? null; + + if ($this->inProgressLookups === $inProgressLookups) { + $this->expressionDependencies[$id] = $dependencies; + $this->expressionTypes[$id] = $type; + } + + return $type; + } + + /** + * Run a callback under the recursion guard, counting a refused run as an in-progress lookup. + * + * @template T + * + * @param callable(): T $callback + * @return null|array{T} + */ + private function guard(string $key, callable $callback): ?array + { + $result = RecursionGuard::run($key, static fn (): array => [$callback()]); + + if ($result === null) { + ++$this->inProgressLookups; + } + + return $result; + } + + /** + * Get the array key PHP stores a config key segment as: a decimal integer string + * becomes an integer. + */ + private function toArrayKey(string $segment): int|string + { + $integer = (int) $segment; + + return (string) $integer === $segment ? $integer : $segment; + } + + /** + * Get the constant type of an array key. + */ + private function getKeyType(int|string $key): ConstantIntegerType|ConstantStringType + { + return is_int($key) ? new ConstantIntegerType($key) : new ConstantStringType($key); + } + + /** + * Get the scope at the config file's return statement, as the statements before + * it leave it. The file is typed on its own, never in the scope of a file + * reading it, so the variables, imports and magic constants are its own. + * + * Returns null when the statements before the return always terminate, or + * while the file's statements are being processed, when they read the file + * itself. + */ + private function getReturnScope(string $path): ?Scope + { + if (array_key_exists($path, $this->returnScopes)) { + return $this->returnScopes[$path]; + } + + $result = $this->guard('hypervel.configFile.' . $path, function () use ($path): ?Scope { + /** @var array{Return_, list, null|string} $parsedConfigFile a file with a value expression parsed */ + $parsedConfigFile = $this->parsedConfigFiles[$path]; + [$return, $statements, $namespace] = $parsedConfigFile; + + $scope = $this->scopeFactory->create(ScopeContext::create($path)); + + if ($namespace !== null) { + $scope = $scope->enterNamespace($namespace); + } + + if ($statements === []) { + $this->configFileDependencies[$path] = []; + + return $scope; + } + + [$statementResult, $dependencies] = $this->collectDependencies(fn () => $this->nodeScopeResolver->processStmtNodes( + $return, + $statements, + $scope, + static function (): void { + }, + StatementContext::createTopLevel(), + )); + + $this->configFileDependencies[$path] = $dependencies; + + return $statementResult->isAlwaysTerminating() ? null : $statementResult->getScope(); + }); + + if ($result === null) { + return null; + } + + return $this->returnScopes[$path] = $result[0]; + } + + /** + * Declare the files and config directories on the scope and on the config files + * and keys being typed, whose cached results declare them again later. + * + * @param DependencyTracker&Scope $scope + * @param array $dependencies each path mapped to whether it is a directory + */ + private function trackDependencies(Scope $scope, array $dependencies): void + { + foreach ($dependencies as $path => $directory) { + if ($directory) { + $scope->trackDirectoryDependency( + $this->runtimeInputs->sourceDirectory($path, ConfigSourceResolver::CONFIG_FILE_PATTERN), + ConfigSourceResolver::CONFIG_FILE_PATTERN, + ); + } else { + $scope->trackFileDependency($this->runtimeInputs->sourcePath($path)); + } + + foreach (array_keys($this->dependencyCollectors) as $index) { + $this->dependencyCollectors[$index][$path] = $directory; + } + } + } + + /** + * Run the callback, collecting the files and directories declared while it runs. + * + * @param callable(): T $callback + * + * @return array{T, array} the callback's result and the paths declared while it ran + * + * @template T + */ + private function collectDependencies(callable $callback): array + { + $this->dependencyCollectors[] = []; + + try { + $result = $callback(); + } finally { + $dependencies = array_pop($this->dependencyCollectors); + } + + return [$result, $dependencies]; + } + + /** + * Load the application's and the framework's config files on first use, once the + * application has booted. + */ + private function loadConfigFiles(): void + { + if ($this->configFiles !== null) { + return; + } + + $configFiles = []; + + foreach ($this->configSourceResolver->getConfigDirectories() as $configPath) { + if (! is_dir($configPath)) { + continue; + } + + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath, FilesystemIterator::SKIP_DOTS)); + + /** @var SplFileInfo $file */ + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $relativePath = substr($files->getSubPathname(), 0, -strlen('.php')); + $configKey = str_replace(['/', DIRECTORY_SEPARATOR], '.', $relativePath); + + // The first config directory that has a file for a key wins + $configFiles[$configKey] ??= $this->fileHelper->normalizePath($file->getPathname()); + + $keyParts = explode('.', $configKey); + + for ($length = count($keyParts) - 1; $length > 0; --$length) { + $this->mergedConfigKeys[implode('.', array_slice($keyParts, 0, $length))] = true; + } + } + } + + // LoadConfiguration loads the files in natural key order, so a file comes before the files nested below it. + ksort($configFiles, SORT_NATURAL); + + $this->configFiles = $configFiles; + + if ($this->configSourceResolver->shouldMergeFrameworkConfiguration()) { + foreach (glob(LoadConfiguration::frameworkConfigPath() . '/*.php') ?: [] as $file) { + $this->frameworkConfigFiles[basename($file, '.php')] = $this->fileHelper->normalizePath($file); + } + } + } + + /** + * Find the value the config file returns: its only return statement + * outside functions and classes, at the top level of the file or of its + * namespace, so it always runs once the statements before it do. + * + * @return null|array{Return_, list, null|string} the return statement, the statements before it and the file's namespace + */ + private function parseConfigFile(string $path): ?array + { + try { + $stmts = $this->parser->parseFile($path); + } catch (ParserErrorsException) { + return null; + } + + $visitor = new class extends NodeVisitorAbstract { + /** @var list */ + public array $returns = []; + + public function enterNode(Node $node): ?int + { + if ($node instanceof Node\FunctionLike || $node instanceof Node\Stmt\ClassLike) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Return_) { + $this->returns[] = $node; + } + + return null; + } + }; + + (new NodeTraverser($visitor))->traverse($stmts); + + if (count($visitor->returns) !== 1) { + return null; + } + + $return = $visitor->returns[0]; + $namespace = null; + $statements = []; + + foreach ($stmts as $stmt) { + if (! $stmt instanceof Node\Stmt\Namespace_) { + $statements[] = $stmt; + continue; + } + + if ($namespace !== null) { + return null; + } + + $namespace = $stmt->name?->toString() ?? ''; + + foreach ($stmt->stmts as $namespacedStmt) { + $statements[] = $namespacedStmt; + } + } + + $returnIndex = array_search($return, $statements, true); + + if ($returnIndex === false) { + return null; + } + + return [$return, array_slice($statements, 0, $returnIndex), $namespace === '' ? null : $namespace]; + } +} diff --git a/src/phpstan/src/Support/ConfigSourceResolver.php b/src/phpstan/src/Support/ConfigSourceResolver.php new file mode 100644 index 0000000000..2a48f76f25 --- /dev/null +++ b/src/phpstan/src/Support/ConfigSourceResolver.php @@ -0,0 +1,1236 @@ + false, 'replaceconfigrecursivelyfrom' => true]; + + /** + * The repository methods that write a key, keyed by lowercase name, with the name of the + * parameter holding the key. + */ + private const array WRITE_METHODS = ['set' => 'key', 'push' => 'key', 'prepend' => 'key', 'offsetset' => 'offset', 'offsetunset' => 'offset']; + + /** + * The repository methods that only read. + */ + private const array READ_METHODS = [ + 'all', 'array', 'boolean', 'collection', 'float', 'get', 'getmany', + 'has', 'integer', 'offsetexists', 'offsetget', 'string', + ]; + + /** @var null|list */ + private ?array $configDirectories = null; + + /** @var null|list */ + private ?array $bootedConfigDirectories = null; + + /** + * The sources discovered from the loaded providers, or null before discovery. + * + * @var null|array{ + * merges: list}>, + * writtenKeys: list, + * unknownWrites: bool, + * packageFiles: array, + * packageDirectories: list, + * } + */ + private ?array $providerSources = null; + + /** + * Provider files parsed during discovery, keyed by path. + * + * @var array> + */ + private array $parsedFiles = []; + + /** + * Create a new resolver instance. + * + * @param list $configuredDirectories + */ + public function __construct( + private FileHelper $fileHelper, + #[AutowiredParameter(ref: '@currentPhpVersionSimpleDirectParser')] + private Parser $parser, + private ReflectionProvider $reflectionProvider, + #[AutowiredParameter(ref: '%configDirectories%')] + private array $configuredDirectories, + ) { + } + + /** + * Get the application's config directories, highest priority first. + * + * Configured directories replace the booted config path. Testbench's workbench + * config directory overlays the booted path when its config discovery is on. + * + * @return list + */ + public function getConfigDirectories(): array + { + if ($this->configDirectories !== null) { + return $this->configDirectories; + } + + return $this->configDirectories = $this->configuredDirectories === [] + ? $this->getBootedConfigDirectories() + : DirectoryResolver::resolve($this->configuredDirectories, $this->fileHelper); + } + + /** + * Get the config directories the booted application loaded, highest priority first. + * + * Testbench's workbench config directory overlays the config path when its config + * discovery is on. + * + * @return list + */ + public function getBootedConfigDirectories(): array + { + if ($this->bootedConfigDirectories !== null) { + return $this->bootedConfigDirectories; + } + + $directories = []; + $bootstrapper = $this->getApplication()->make(LoadConfiguration::class); + + if ($bootstrapper instanceof LoadConfigurationWithWorkbench + && (fn (): bool => $this->usesWorkbenchConfigFile)->call($bootstrapper)) { + $directories[] = workbench_path('config'); + } + + $directories[] = $this->getApplication()->configPath(); + + return $this->bootedConfigDirectories = array_map($this->fileHelper->normalizePath(...), $directories); + } + + /** + * Get the paths whose contents can change a config key's loaded value, each mapped to + * whether it is a directory of config files. + * + * In each config directory, these are the file of every prefix of the key, existing or + * not, and the directory below the key, whose files make its value a merged one. Then + * the framework's file for the key and the package files merged into an overlapping + * key. + * + * @param list $directories the config directories the value loads from + * @return array + */ + public function getKeySources(string $key, array $directories): array + { + $sources = []; + $segments = explode('.', $key); + + foreach ($directories as $directory) { + for ($length = count($segments); $length > 0; --$length) { + $sources[$directory . '/' . implode('/', array_slice($segments, 0, $length)) . '.php'] = false; + } + + $sources[$directory . '/' . implode('/', $segments)] = true; + } + + if ($this->shouldMergeFrameworkConfiguration()) { + $sources[$this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath() . '/' . $segments[0] . '.php')] = false; + } + + foreach ($this->getMerges() as $merge) { + if ($merge['path'] !== null && $this->keysOverlap($key, $merge['key'])) { + $sources[$merge['path']] = false; + } + } + + return $sources; + } + + /** + * Get the paths whose contents can change any loaded config value, each mapped to whether + * it is a directory of config files: the config directories, the framework's config + * directory and the package files merged into the configuration. + * + * @param list $directories the config directories the values load from + * @return array + */ + public function getAllSources(array $directories): array + { + $sources = array_fill_keys($directories, true); + + if ($this->shouldMergeFrameworkConfiguration()) { + $sources[$this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath())] = true; + } + + foreach ($this->getMerges() as $merge) { + if ($merge['path'] !== null) { + $sources[$merge['path']] = false; + } + } + + return $sources; + } + + /** + * Determine whether the framework's config files are merged beneath the application's. + */ + public function shouldMergeFrameworkConfiguration(): bool + { + return $this->getApplication()->shouldMergeFrameworkConfiguration(); + } + + /** + * Get the named-entry options LoadConfiguration merges for a framework config file. + * + * @return list + */ + public function getMergeableOptions(string $name): array + { + $bootstrapper = $this->getApplication()->make(LoadConfiguration::class); + + return array_values((fn (): array => $this->mergeableOptions($name))->call($bootstrapper)); + } + + /** + * Get the package config files providers merge, in registration order. + * + * A merge is definite when every boot runs it; other merges may or may not run. + * The options are the provider's named-entry options for the key. + * + * @return list}> + */ + public function getMerges(): array + { + return $this->getProviderSources()['merges']; + } + + /** + * Get the config keys that providers and bootstrappers write. + * + * @return list + */ + public function getWrittenKeys(): array + { + return $this->getProviderSources()['writtenKeys']; + } + + /** + * Determine whether a provider writes config keys that cannot be found statically. + */ + public function hasUnknownWrites(): bool + { + return $this->getProviderSources()['unknownWrites']; + } + + /** + * Determine whether the file is a package config file: one that a provider merges, or + * one that a provider publishes into the config directory. + */ + public function isPackageConfigFile(string $path): bool + { + $sources = $this->getProviderSources(); + $path = $this->fileHelper->normalizePath($path); + + if (isset($sources['packageFiles'][$path])) { + return true; + } + + foreach ($sources['packageDirectories'] as $directory) { + if (str_starts_with($path, $directory . DIRECTORY_SEPARATOR)) { + return true; + } + } + + return false; + } + + /** + * Discover the configuration sources of the loaded providers. + * + * @return array{ + * merges: list}>, + * writtenKeys: list, + * unknownWrites: bool, + * packageFiles: array, + * packageDirectories: list, + * } + */ + private function getProviderSources(): array + { + if ($this->providerSources !== null) { + return $this->providerSources; + } + + $app = $this->getApplication(); + $merges = []; + $writtenKeys = []; + $mergedFiles = []; + $unknownWrites = false; + + foreach ($app->getProviders(ServiceProvider::class) as $provider) { + $unknownWrites = $this->discoverProvider($provider, $merges, $writtenKeys, $mergedFiles) || $unknownWrites; + } + + // Providers run in an order this analysis does not follow, so merges of the same + // or nested keys by different providers are not definite. + foreach ($merges as $index => $merge) { + foreach ($merges as $other) { + if ($merge['definite'] && $other['provider'] !== $merge['provider'] && $this->keysOverlap($merge['key'], $other['key'])) { + $merges[$index]['definite'] = false; + } + } + } + + // Direct writes after LoadConfiguration are recorded, such as Testbench's testing + // connection and provider set() calls. Only their keys are read. + $mutations = (fn (): array => $this->mutations)->call($app->make(ConfigMutationTracker::class)); + + foreach ($mutations as $mutation) { + if (! $mutation instanceof Closure) { + foreach (array_keys($mutation) as $key) { + $writtenKeys[] = (string) $key; + } + } + } + + $packageFiles = array_fill_keys($mergedFiles, true); + $packageDirectories = []; + + $configDirectory = $this->fileHelper->normalizePath($app->configPath()); + + foreach (ServiceProvider::pathsToPublish() as $source => $target) { + $target = $this->fileHelper->normalizePath($target); + + if ($target !== $configDirectory && ! str_starts_with($target, $configDirectory . DIRECTORY_SEPARATOR)) { + continue; + } + + if (is_dir($source)) { + $packageDirectories[] = $this->fileHelper->normalizePath($source); + } else { + $packageFiles[$this->fileHelper->normalizePath($source)] = true; + } + } + + $this->parsedFiles = []; + + return $this->providerSources = [ + 'merges' => array_map(static function (array $merge): array { + unset($merge['provider']); + + return $merge; + }, $merges), + // The direct pass also scans configureUsing() callbacks, so keys repeat. + 'writtenKeys' => array_values(array_unique($writtenKeys)), + 'unknownWrites' => $unknownWrites, + 'packageFiles' => $packageFiles, + 'packageDirectories' => $packageDirectories, + ]; + } + + /** + * Collect a provider's merges and the keys it writes, in configureUsing() callbacks + * and directly. + * + * Returns whether the provider writes keys that cannot be found statically. + * + * @param list, provider: string}> $merges + * @param list $writtenKeys + * @param list $mergedFiles the files merged, including those whose key is unknown + */ + private function discoverProvider(ServiceProvider $provider, array &$merges, array &$writtenKeys, array &$mergedFiles): bool + { + // An anonymous provider cannot be reflected by name. Neither can any provider when PHPStan + // validates stub files that are not in the vendor directory, against the stubs alone. + if (! $this->reflectionProvider->hasClass($provider::class)) { + return true; + } + + $providerClass = $this->reflectionProvider->getClass($provider::class); + $classLikes = $this->getClassLikes($providerClass); + + if ($classLikes === null) { + return true; + } + + /** @var list $definiteCalls */ + $definiteCalls = []; + $this->collectDefiniteMerges($providerClass, $providerClass, 'register', $definiteCalls); + + $calls = []; + + foreach ($definiteCalls as [$call, $file, $selfClass, $recursive]) { + $calls[] = [$call, $file, $selfClass, $recursive, true]; + } + + $reached = []; + + foreach ($definiteCalls as [$call]) { + $reached[spl_object_id($call)] = true; + } + + $unknownWrites = false; + $nodeFinder = new NodeFinder; + + foreach ($classLikes as [$classLike, $file, $selfClass]) { + $context = [$file, $selfClass, $provider]; + + foreach ($nodeFinder->findInstanceOf($classLike, MethodCall::class) as $call) { + if (! $this->isThisCall($call) || ! $call->name instanceof Identifier) { + continue; + } + + $name = $call->name->toLowerString(); + + // A callable created from a merge method has no arguments, so its merge stays unknown. + if (isset(self::MERGE_METHODS[$name]) && ! isset($reached[spl_object_id($call)])) { + $calls[] = [$call, $file, $selfClass, self::MERGE_METHODS[$name], false]; + } + + if ($name === 'configureusing') { + $unknownWrites = ! $this->collectCallbackWrites($call, $context, $writtenKeys) || $unknownWrites; + } + } + + foreach ($nodeFinder->findInstanceOf($classLike, FunctionLike::class) as $function) { + $unknownWrites = ! $this->collectDirectWrites($function, $context, $writtenKeys) || $unknownWrites; + } + } + + foreach ($calls as [$call, $file, $selfClass, $recursive, $definite]) { + $key = $this->evaluateArgument($call, 'key', 1, $file, $selfClass, $provider); + $path = $this->evaluateArgument($call, 'path', 0, $file, $selfClass, $provider); + $path = is_string($path) ? $this->fileHelper->normalizePath($path) : null; + + // A known file is a package config file even when the key it merges under is not known. + if ($path !== null) { + $mergedFiles[] = $path; + } + + if (! is_string($key)) { + $unknownWrites = true; + + continue; + } + + $merges[] = [ + 'key' => $key, + 'path' => $path, + 'recursive' => $recursive, + 'definite' => $definite && $path !== null, + 'options' => $recursive ? [] : array_values((fn (): array => $this->mergeableOptions($key))->call($provider)), + 'provider' => $provider::class, + ]; + } + + return $unknownWrites; + } + + /** + * Collect the merge calls on the straight line through a provider method: its top-level + * statements, following parent::register() and $this->helper() statements, until a + * statement that can return. A helper called twice runs twice. + * + * @param list $definiteCalls the merge calls, files, self classes and whether each merge is recursive + */ + private function collectDefiniteMerges(ClassReflection $providerClass, ClassReflection $startClass, string $methodName, array &$definiteCalls): void + { + $method = $this->findMethod($startClass, $methodName); + + if ($method === null) { + return; + } + + [$methodNode, $file, $selfClass, $declaringClass] = $method; + + RecursionGuard::run('hypervel.configProvider.' . $declaringClass->getName() . '::' . $methodNode->name->toLowerString(), function () use ($providerClass, $methodNode, $file, $selfClass, $declaringClass, &$definiteCalls): bool { + foreach ($methodNode->stmts ?? [] as $stmt) { + // Creating a callable runs nothing, so it is neither a merge nor a call to follow. + $call = $stmt instanceof Expression && $stmt->expr instanceof CallLike && ! $stmt->expr->isPartialFunctionApplication() + ? $stmt->expr + : null; + + if ($call instanceof MethodCall && $this->isThisCall($call) && $call->name instanceof Identifier) { + if (isset(self::MERGE_METHODS[$call->name->toLowerString()])) { + $definiteCalls[] = [$call, $file, $selfClass, self::MERGE_METHODS[$call->name->toLowerString()]]; + } else { + $this->collectDefiniteMerges($providerClass, $providerClass, $call->name->toString(), $definiteCalls); + } + } + + if ($call instanceof StaticCall + && $call->class instanceof Name + && $call->class->toLowerString() === 'parent' + && $call->name instanceof Identifier + && $call->name->toLowerString() === 'register' + && $declaringClass->getParentClass() !== null) { + $this->collectDefiniteMerges($providerClass, $declaringClass->getParentClass(), 'register', $definiteCalls); + } + + if ($this->canReturn($stmt)) { + break; + } + } + + return true; + }); + } + + /** + * Collect the keys a configureUsing() callback writes. + * + * The tracker records only the callback, so every write it can make must be found here. + * Returns false when one cannot be: the callback is not a closure, as when + * configureUsing(...) creates a callable, or its repository escapes or receives an + * unknown call. + * + * @param array{string, string, ServiceProvider} $context the file, self class and provider + * @param list $writtenKeys + */ + private function collectCallbackWrites(MethodCall $call, array $context, array &$writtenKeys): bool + { + $callback = $call->getArg('callback', 0)?->value; + + if (! $callback instanceof ClosureExpression && ! $callback instanceof ArrowFunction) { + return false; + } + + $receivers = []; + + if ($callback->params !== []) { + $variable = $callback->params[0]->var; + + if ($callback->params[0]->byRef || $callback->params[0]->variadic || ! $variable instanceof Variable || ! is_string($variable->name)) { + return false; + } + + $receivers[$variable->name] = true; + } + + foreach ($callback->getStmts() as $stmt) { + if (! $this->collectWrites($stmt, $receivers, true, $context, $writtenKeys)) { + return false; + } + } + + return true; + } + + /** + * Collect the keys a provider function writes directly through a recognized repository, + * including in branches the analysis boot did not run. + * + * The tracker already records every write the boot ran, whatever its receiver, so a + * repository flow this cannot follow, such as a local variable, is left to it. Returns + * false for a recognized write whose key cannot be bounded. + * + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectDirectWrites(FunctionLike $function, array $context, array &$writtenKeys): bool + { + $receivers = []; + + foreach ($function->getParams() as $parameter) { + if ($parameter->var instanceof Variable && is_string($parameter->var->name) && $this->isRepositoryType($parameter->type)) { + $receivers[$parameter->var->name] = true; + } + } + + foreach ($function->getStmts() ?? [] as $stmt) { + if (! $this->collectWrites($stmt, $receivers, false, $context, $writtenKeys)) { + return false; + } + } + + return true; + } + + /** + * Collect the config writes in a node. + * + * Writes are calls and offset writes on a recognized repository, the config([...]) + * helper and the Config facade. Returns false when a write's key cannot be bounded. When + * strict, as for callbacks, it also returns false when the repository escapes or receives + * an unknown call, including a bare config() used as a value. Otherwise those flows are + * left to the tracker, and nested functions are skipped, being collected on their own. + * + * @param array $receivers the variables holding the repository + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectWrites(Node $node, array $receivers, bool $strict, array $context, array &$writtenKeys): bool + { + $children = $node->getSubNodeNames(); + + if (! $strict && $node instanceof FunctionLike) { + return true; + } + + if (($node instanceof MethodCall && $this->isRepository($node->var, $receivers, $context)) + || ($node instanceof StaticCall && $node->class instanceof Name && $this->isConfigFacade($node->class))) { + if (! $this->collectMethodWrites($node, $strict, $context, $writtenKeys)) { + return false; + } + + $children = ['args']; + } elseif ($node instanceof FuncCall && $node->name instanceof Name && $this->isGlobalFunction($node->name, 'config') && $node->getRawArgs() !== []) { + // config(...) creates a callable that can later write any key. + if ($node->isPartialFunctionApplication()) { + return ! $strict; + } + + $key = $node->getArg('key', 0)?->value; + + if ($key instanceof Expr\Array_) { + if (! $this->collectArrayKeys($key, $context, $writtenKeys)) { + return false; + } + } elseif ($strict && ! $key instanceof Scalar && ! $key instanceof Concat) { + // Another key, including an unpacked one, may be an array of values to set. + return false; + } + } elseif (($node instanceof Assign || $node instanceof AssignRef || $node instanceof AssignOp) + && $node->var instanceof ArrayDimFetch + && $this->getRootDimension($node->var, $receivers, $context) !== null) { + if (! $this->collectDimensionWrite($node->var, $receivers, $context, $writtenKeys)) { + return false; + } + + $children = ['expr']; + } elseif ($node instanceof Unset_) { + foreach ($node->vars as $variable) { + if ($variable instanceof ArrayDimFetch && $this->getRootDimension($variable, $receivers, $context) !== null) { + if (! $this->collectDimensionWrite($variable, $receivers, $context, $writtenKeys)) { + return false; + } + } elseif (! $this->collectWrites($variable, $receivers, $strict, $context, $writtenKeys)) { + return false; + } + } + + $children = []; + } elseif ($node instanceof ArrayDimFetch && $this->getRootDimension($node, $receivers, $context) !== null) { + // An offset read; only its dimensions can hold writes. + for ($fetch = $node; $fetch instanceof ArrayDimFetch; $fetch = $fetch->var) { + if ($fetch->dim !== null && ! $this->collectWrites($fetch->dim, $receivers, $strict, $context, $writtenKeys)) { + return false; + } + } + + $children = []; + } elseif ($node instanceof ClosureExpression) { + foreach ($node->uses as $use) { + if (is_string($use->var->name) && isset($receivers[$use->var->name])) { + return false; + } + } + } elseif ($node instanceof Expr && $this->isRepository($node, $receivers, $context)) { + // The repository is passed, stored or returned. + return ! $strict; + } + + foreach ($children as $child) { + $subNodes = $node->{$child}; + + foreach (is_array($subNodes) ? $subNodes : [$subNodes] as $subNode) { + if ($subNode instanceof Node && ! $this->collectWrites($subNode, $receivers, $strict, $context, $writtenKeys)) { + return false; + } + } + } + + return true; + } + + /** + * Collect the key a repository or Config facade method call writes. + * + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectMethodWrites(MethodCall|StaticCall $call, bool $strict, array $context, array &$writtenKeys): bool + { + if (! $call->name instanceof Identifier) { + return ! $strict; + } + + $name = $call->name->toLowerString(); + + // A callable created from a read method can only read. + if (in_array($name, self::READ_METHODS, true)) { + return true; + } + + // An unknown method, or a callable created from a write method, may write any key. + if ($call->isPartialFunctionApplication() || ! isset(self::WRITE_METHODS[$name])) { + return ! $strict; + } + + // An unpacked key is not found, so it stays unknown. + $key = $call->getArg(self::WRITE_METHODS[$name], 0)?->value; + + if ($name === 'set' && $key instanceof Expr\Array_) { + return $this->collectArrayKeys($key, $context, $writtenKeys); + } + + return $key !== null && $this->collectKey($key, $context, $writtenKeys); + } + + /** + * Collect the keys of an array of config values to set. + * + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectArrayKeys(Expr\Array_ $array, array $context, array &$writtenKeys): bool + { + foreach ($array->items as $item) { + if ($item->key === null || ! $this->collectKey($item->key, $context, $writtenKeys)) { + return false; + } + } + + return true; + } + + /** + * Collect the key an offset write on a recognized repository writes. + * + * @param array $receivers + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectDimensionWrite(ArrayDimFetch $fetch, array $receivers, array $context, array &$writtenKeys): bool + { + /** @var ArrayDimFetch $root */ + $root = $this->getRootDimension($fetch, $receivers, $context); + + return $root->dim !== null && $this->collectKey($root->dim, $context, $writtenKeys); + } + + /** + * Collect a written key. A key that does not resolve is bounded by its constant leading + * segments: `'filesystems.disks.' . $name` writes below `filesystems.disks`, and + * `'filesystems.disks' . $suffix` below `filesystems`, since the suffix may extend `disks`. + * + * Returns false when not even the first segment is constant. + * + * @param array{string, string, ServiceProvider} $context + * @param list $writtenKeys + */ + private function collectKey(Expr $key, array $context, array &$writtenKeys): bool + { + $value = $this->evaluate($key, ...$context); + + if (is_string($value) || is_int($value)) { + $writtenKeys[] = (string) $value; + + return true; + } + + $prefix = $this->getConstantPrefix($key, $context); + $boundary = $prefix === null ? false : strrpos($prefix, '.'); + + if ($boundary === false || $boundary === 0) { + return false; + } + + $writtenKeys[] = substr($prefix, 0, $boundary); + + return true; + } + + /** + * Get the constant leading part of a string expression built by concatenation or + * interpolation, or null when it has none. + * + * @param array{string, string, ServiceProvider} $context + */ + private function getConstantPrefix(Expr $expr, array $context): ?string + { + if ($expr instanceof Concat) { + $left = $this->evaluate($expr->left, ...$context); + + return is_string($left) + ? $left . ($this->getConstantPrefix($expr->right, $context) ?? '') + : $this->getConstantPrefix($expr->left, $context); + } + + if ($expr instanceof InterpolatedString) { + $prefix = ''; + + foreach ($expr->parts as $part) { + if (! $part instanceof InterpolatedStringPart) { + break; + } + + $prefix .= $part->value; + } + + return $prefix; + } + + $value = $this->evaluate($expr, ...$context); + + return is_string($value) ? $value : null; + } + + /** + * Get the offset fetch applied directly to a recognized repository beneath the fetch, if any. + * + * @param array $receivers + * @param array{string, string, ServiceProvider} $context + */ + private function getRootDimension(ArrayDimFetch $fetch, array $receivers, array $context): ?ArrayDimFetch + { + while ($fetch->var instanceof ArrayDimFetch) { + $fetch = $fetch->var; + } + + return $this->isRepository($fetch->var, $receivers, $context) ? $fetch : null; + } + + /** + * Determine whether the expression is the config repository: a variable holding it, a + * bare config(), app() or resolve() with its id, or the provider application's make() + * or get() with its id. The container has no ArrayAccess, so `$this->app['config']` is + * not a form. + * + * @param array $receivers + * @param array{string, string, ServiceProvider} $context + */ + private function isRepository(Expr $expr, array $receivers, array $context): bool + { + if ($expr instanceof Variable) { + return is_string($expr->name) && isset($receivers[$expr->name]); + } + + // A created callable is not the repository: getArg() finds no argument past a "..." placeholder. + if ($expr instanceof FuncCall && $expr->name instanceof Name) { + if ($this->isGlobalFunction($expr->name, 'config')) { + return $expr->getRawArgs() === []; + } + + $id = match (true) { + $this->isGlobalFunction($expr->name, 'app') => $expr->getArg('abstract', 0), + $this->isGlobalFunction($expr->name, 'resolve') => $expr->getArg('name', 0), + default => null, + }; + + return $id !== null && $this->isRepositoryId($id->value, $context); + } + + if (! $expr instanceof MethodCall + || ! $expr->var instanceof PropertyFetch + || ! $expr->var->var instanceof Variable + || $expr->var->var->name !== 'this' + || ! $expr->var->name instanceof Identifier + || $expr->var->name->toString() !== 'app' + || ! $expr->name instanceof Identifier) { + return false; + } + + $id = match ($expr->name->toLowerString()) { + 'make' => $expr->getArg('abstract', 0), + 'get' => $expr->getArg('id', 0), + default => null, + }; + + return $id !== null && $this->isRepositoryId($id->value, $context); + } + + /** + * Determine whether a container id resolves the config repository. + * + * @param array{string, string, ServiceProvider} $context + */ + private function isRepositoryId(Expr $id, array $context): bool + { + $id = $this->evaluate($id, ...$context); + + return is_string($id) && ($id === 'config' || $this->isRepositoryClass($id)); + } + + /** + * Determine whether a parameter type is the config repository's contract or a class implementing it. + */ + private function isRepositoryType(?Node $type): bool + { + if ($type instanceof NullableType) { + $type = $type->type; + } + + return $type instanceof Name && ! $type->isSpecialClassName() && $this->isRepositoryClass($type->toString()); + } + + /** + * Determine whether the class is the config repository's contract or implements it. + */ + private function isRepositoryClass(string $class): bool + { + return $this->reflectionProvider->hasClass($class) + && $this->reflectionProvider->getClass($class)->is(RepositoryContract::class); + } + + /** + * Determine whether the class name refers to the Config facade. + */ + private function isConfigFacade(Name $class): bool + { + $name = $class->toString(); + + return ! $class->isSpecialClassName() + && $this->reflectionProvider->hasClass($name) + && $this->reflectionProvider->getClass($name)->is(Config::class); + } + + /** + * Determine whether the call's name refers to the given global function. + */ + private function isGlobalFunction(Name $name, string $function): bool + { + $namespacedName = $name->getAttribute('namespacedName'); + + // An unqualified call resolves to a namespaced function of the same name when one exists. + if ($namespacedName instanceof FullyQualified && function_exists($namespacedName->toString())) { + return false; + } + + return $name->toLowerString() === $function; + } + + /** + * Determine whether the call is made on $this. + */ + private function isThisCall(MethodCall $call): bool + { + return $call->var instanceof Variable && $call->var->name === 'this'; + } + + /** + * Determine whether two config keys are the same or one is nested in the other. + */ + private function keysOverlap(string $key, string $other): bool + { + return $key === $other || str_starts_with($key, $other . '.') || str_starts_with($other, $key . '.'); + } + + /** + * Determine whether the statement can return from its function. + */ + private function canReturn(Stmt $stmt): bool + { + $visitor = new class extends NodeVisitorAbstract { + public bool $returns = false; + + public function enterNode(Node $node): ?int + { + if ($node instanceof Node\FunctionLike || $node instanceof ClassLike) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Return_) { + $this->returns = true; + + return NodeVisitor::STOP_TRAVERSAL; + } + + return null; + } + }; + + (new NodeTraverser($visitor))->traverse([$stmt]); + + return $visitor->returns; + } + + /** + * Evaluate a call argument as a constant expression, or return null. + */ + private function evaluateArgument(MethodCall $call, string $name, int $position, string $file, string $selfClass, ServiceProvider $provider): mixed + { + $argument = $call->getArg($name, $position); + + return $argument === null ? null : $this->evaluate($argument->value, $file, $selfClass, $provider); + } + + /** + * Evaluate a constant expression in provider code, or return null when it is not constant. + * + * Besides literals, it resolves the file's __DIR__ and __FILE__, dirname() calls, class + * constants and the current value of a static string property, which providers such as + * Sentry's use as boot metadata. PHPStan does not fold path constants by default. + */ + private function evaluate(Expr $expr, string $file, string $selfClass, ServiceProvider $provider): mixed + { + $evaluator = new ConstExprEvaluator(function (Expr $expr) use (&$evaluator, $file, $selfClass, $provider): mixed { + // Errors such as an undefined constant surface as ConstExprEvaluationException through evaluateSilently(). + if ($expr instanceof Dir) { + return dirname($file); + } + + if ($expr instanceof File) { + return $file; + } + + if ($expr instanceof FuncCall && $expr->name instanceof Name && $this->isGlobalFunction($expr->name, 'dirname')) { + $path = $expr->getArg('path', 0); + $levels = $expr->getArg('levels', 1); + + if ($path === null || array_any($expr->getArgs(), static fn (Node\Arg $argument): bool => $argument->unpack)) { + throw new ConstExprEvaluationException('Unpacked arguments are not constant.'); + } + + return dirname($evaluator->evaluateDirectly($path->value), $levels === null ? 1 : $evaluator->evaluateDirectly($levels->value)); + } + + if ($expr instanceof ClassConstFetch && $expr->class instanceof Name && $expr->name instanceof Identifier) { + $class = $this->resolveClassName($expr->class, $selfClass, $provider); + + // Reflection reads protected and private constants, which providers often use. + return $expr->name->toLowerString() === 'class' + ? $class + : (new ReflectionClassConstant($class, $expr->name->toString()))->getValue(); + } + + if ($expr instanceof StaticPropertyFetch + && $expr->class instanceof Name + && in_array($expr->class->toLowerString(), ['self', 'static'], true) + && $expr->name instanceof VarLikeIdentifier) { + $value = (new ReflectionProperty($this->resolveClassName($expr->class, $selfClass, $provider), $expr->name->toString()))->getValue(); + + if (is_string($value)) { + return $value; + } + } + + throw new ConstExprEvaluationException('Expression is not constant.'); + }); + + try { + return $evaluator->evaluateSilently($expr); + } catch (ConstExprEvaluationException) { + return null; + } + } + + /** + * Resolve a class name in provider code, including self, static and parent. + * + * @throws ConstExprEvaluationException + */ + private function resolveClassName(Name $class, string $selfClass, ServiceProvider $provider): string + { + return match ($class->toLowerString()) { + 'self' => $selfClass, + 'static' => $provider::class, + 'parent' => $this->reflectionProvider->getClass($selfClass)->getParentClass()?->getName() + ?? throw new ConstExprEvaluationException('The class has no parent.'), + default => $class->toString(), + }; + } + + /** + * Find a method's declaration, searching the class, its traits and its parents below ServiceProvider. + * + * @return null|array{ClassMethod, string, string, ClassReflection} the method, its file, its self class and its declaring class + */ + private function findMethod(ClassReflection $class, string $methodName): ?array + { + for ($current = $class; $current !== null && $current->getName() !== ServiceProvider::class; $current = $current->getParentClass()) { + foreach ($this->getOwnClassLikes($current) ?? [] as [$classLike, $file]) { + $method = $classLike->getMethod($methodName); + + if ($method !== null) { + return [$method, $file, $current->getName(), $current]; + } + } + } + + return null; + } + + /** + * Get the class declarations of a provider's hierarchy below ServiceProvider, with their traits. + * + * Returns null when a declaration has no source file to read. + * + * @return null|list each declaration, its file and its self class + */ + private function getClassLikes(ClassReflection $class): ?array + { + $classLikes = []; + + for ($current = $class; $current !== null && $current->getName() !== ServiceProvider::class; $current = $current->getParentClass()) { + $ownClassLikes = $this->getOwnClassLikes($current); + + if ($ownClassLikes === null) { + return null; + } + + foreach ($ownClassLikes as [$classLike, $file]) { + $classLikes[] = [$classLike, $file, $current->getName()]; + } + } + + return $classLikes; + } + + /** + * Get a class's own declaration followed by those of its traits, depth first. + * + * @return null|list + */ + private function getOwnClassLikes(ClassReflection $class): ?array + { + $file = $class->getFileName(); + + if ($file === null) { + return null; + } + + $this->parsedFiles[$file] ??= $this->parser->parseFile($file); + + $classLike = (new NodeFinder)->findFirst( + $this->parsedFiles[$file], + static fn (Node $node): bool => $node instanceof ClassLike + && strtolower($node->namespacedName?->toString() ?? '') === strtolower($class->getName()), + ); + + if (! $classLike instanceof ClassLike) { + return null; + } + + $classLikes = [[$classLike, $file]]; + + foreach ($class->getTraits() as $trait) { + $traitClassLikes = $this->getOwnClassLikes($trait); + + if ($traitClassLikes === null) { + return null; + } + + foreach ($traitClassLikes as $traitClassLike) { + $classLikes[] = $traitClassLike; + } + } + + return $classLikes; + } + + /** + * Get the booted application. + */ + private function getApplication(): Application + { + return $this->getContainer()->make(Application::class); + } +} diff --git a/src/phpstan/src/Support/ConstructorParser.php b/src/phpstan/src/Support/ConstructorParser.php new file mode 100644 index 0000000000..2db4f4e890 --- /dev/null +++ b/src/phpstan/src/Support/ConstructorParser.php @@ -0,0 +1,251 @@ +, methods: array}|true> + */ + private array $writes = []; + + /** + * Create a new constructor parser instance. + */ + public function __construct( + #[AutowiredParameter(ref: '@cachedRichParser')] + private Parser $parser, + ) { + } + + /** + * Find the constructor the class's instances run (its own, an inherited one or a trait's), with the class + * declaring it, which `parent::` is relative to. A trait's constructor is declared by the class using it. + * Null when there is no constructor in source. + * + * @return null|array{ClassMethod, ClassReflection} + */ + public function findConstructor(ClassReflection $classReflection): ?array + { + $native = $classReflection->getNativeReflection(); + + if (! $native->hasMethod('__construct')) { + return null; + } + + $constructor = $native->getMethod('__construct'); + $fileName = $constructor->getFileName(); + + if ($fileName === false) { + return null; + } + + // A trait method imported as the constructor keeps its own name, so the node is found by its position. + $method = $constructor->getBetterReflection(); + $source = $method->getLocatedSource()->getSource(); + $node = (new NodeFinder)->findFirst( + $this->parser->parseFile($fileName), + static fn (Node $node): bool => $node instanceof ClassMethod + && $node->getStartLine() === $method->getStartLine() + && self::column($source, $node->getStartFilePos()) === $method->getStartColumn(), + ); + + if (! $node instanceof ClassMethod) { + throw new LogicException(sprintf('Constructor of [%s] was not found in [%s].', $classReflection->getName(), $fileName)); + } + + $declaringClass = $constructor->getDeclaringClass()->getName(); + + return [ + $node, + $classReflection->getAncestorWithClassName($declaringClass) + ?? throw new LogicException(sprintf('Constructor declaring class [%s] is not an ancestor of [%s].', $declaringClass, $classReflection->getName())), + ]; + } + + /** + * Determine whether the constructor the class's instances run may change the property, following + * `parent::__construct()`: an assignment to it or through an array dimension of it, a promoted parameter, + * or a call on the instance to one of the given setters. + * + * The bodies of the other methods it calls are not read. + * + * @param list $setters + */ + public function writesProperty(ClassReflection $classReflection, string $property, array $setters = []): bool + { + $writes = $this->writes($classReflection); + + if ($writes === true || isset($writes['properties'][$property])) { + return true; + } + + foreach ($setters as $setter) { + if (isset($writes['methods'][strtolower($setter)])) { + return true; + } + } + + return false; + } + + /** + * Get what the constructor the class's instances run writes. + * + * @return array{properties: array, methods: array}|true + */ + private function writes(ClassReflection $classReflection): array|true + { + return $this->writes[$classReflection->getName()] ??= $this->resolveWrites($classReflection); + } + + /** + * Resolve what the constructor the class's instances run writes. + * + * @return array{properties: array, methods: array}|true + */ + private function resolveWrites(ClassReflection $classReflection): array|true + { + $writes = ['properties' => [], 'methods' => []]; + $constructor = $this->findConstructor($classReflection); + + if ($constructor === null) { + return $writes; + } + + [$node, $declaringClass] = $constructor; + + foreach ($node->params as $param) { + if ($param->isPromoted() && $param->var instanceof Variable && is_string($param->var->name)) { + $writes['properties'][$param->var->name] = true; + } + } + + /** @var list $nodes */ + $nodes = (new NodeFinder)->find( + $node->stmts ?? [], + static fn (Node $node): bool => $node instanceof Assign || $node instanceof AssignOp || $node instanceof AssignRef + || $node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof StaticCall, + ); + + foreach ($nodes as $expression) { + if ($expression instanceof StaticCall) { + if (! $expression->class instanceof Name || ! in_array($expression->class->toLowerString(), ['self', 'static', 'parent'], true)) { + continue; + } + + if (! $expression->name instanceof Identifier) { + return true; + } + + if ($expression->class->toLowerString() !== 'parent' || $expression->name->toLowerString() !== '__construct') { + $writes['methods'][$expression->name->toLowerString()] = true; + + continue; + } + + $parent = $declaringClass->getParentClass(); + + if ($parent === null) { + continue; + } + + $parentWrites = $this->writes($parent); + + if ($parentWrites === true) { + return true; + } + + $writes['properties'] += $parentWrites['properties']; + $writes['methods'] += $parentWrites['methods']; + + continue; + } + + if ($expression instanceof MethodCall || $expression instanceof NullsafeMethodCall) { + // A call chain continues on the instance through setters returning it. + $target = $expression->var; + + while ($target instanceof MethodCall || $target instanceof NullsafeMethodCall) { + $target = $target->var; + } + + if (! $target instanceof Variable || $target->name !== 'this') { + continue; + } + + if (! $expression->name instanceof Identifier) { + return true; + } + + $writes['methods'][$expression->name->toLowerString()] = true; + + continue; + } + + $target = $expression->var; + + while ($target instanceof ArrayDimFetch) { + $target = $target->var; + } + + if (! $target instanceof PropertyFetch || ! $target->var instanceof Variable || $target->var->name !== 'this') { + continue; + } + + if (! $target->name instanceof Identifier) { + return true; + } + + $writes['properties'][$target->name->toString()] = true; + } + + return $writes; + } + + /** + * Get the column of a source position, as BetterReflection counts it for a method's start column. + */ + private static function column(string $source, int $position): int + { + $lineStart = strrpos($source, "\n", $position - strlen($source)); + + return $lineStart === false ? $position + 1 : $position - $lineStart; + } +} diff --git a/src/phpstan/src/Support/DirectoryResolver.php b/src/phpstan/src/Support/DirectoryResolver.php new file mode 100644 index 0000000000..be093687d9 --- /dev/null +++ b/src/phpstan/src/Support/DirectoryResolver.php @@ -0,0 +1,56 @@ + + */ + public static function resolve(array $paths, FileHelper $fileHelper): array + { + $directories = []; + + foreach ($paths as $path) { + $path = $fileHelper->absolutizePath($path); + + $matches = is_dir($path) || strpbrk($path, '*?[') === false + ? [$path] + : (glob($path, GLOB_ONLYDIR) ?: []); + + foreach ($matches as $directory) { + $directory = rtrim($fileHelper->normalizePath($directory), '/\\'); + $directories[$directory] = $directory; + } + } + + return array_values($directories); + } +} diff --git a/src/phpstan/src/Support/EnvironmentReadVisitor.php b/src/phpstan/src/Support/EnvironmentReadVisitor.php new file mode 100644 index 0000000000..55851b3bfe --- /dev/null +++ b/src/phpstan/src/Support/EnvironmentReadVisitor.php @@ -0,0 +1,106 @@ + + */ + public array $names = []; + + /** + * Whether a read's name is computed outside the readers and modeled methods. + */ + public bool $dynamic = false; + + /** + * The enclosing class-likes, null for an anonymous class. + * + * @var list + */ + private array $classes = []; + + /** + * The enclosing named functions and methods; closures run in theirs. + * + * @var list + */ + private array $functions = []; + + /** + * Create a new visitor instance. + */ + public function __construct( + private EnvironmentVariableScanner $scanner, + ) { + } + + /** + * Record the read a node makes, after entering the class or function it declares. + */ + public function enterNode(Node $node): null + { + // The name resolver leaves an anonymous class's name unset. + if ($node instanceof ClassLike) { + $this->classes[] = isset($node->namespacedName) ? $node->namespacedName->toString() : null; + } elseif ($node instanceof ClassMethod) { + $this->functions[] = $this->classes[array_key_last($this->classes)] . '::' . $node->name->toString(); + } elseif ($node instanceof Function_) { + $this->functions[] = isset($node->namespacedName) ? $node->namespacedName->toString() : null; + } + + $class = $this->classes === [] ? null : $this->classes[array_key_last($this->classes)]; + $name = $this->scanner->readName($node, $class); + + if ($name === null) { + return null; + } + + if ($name !== '') { + $this->names[$name] = true; + } elseif (! $this->scanner->readsComputedNames( + $class, + $this->functions === [] ? null : $this->functions[array_key_last($this->functions)], + )) { + $this->dynamic = true; + } + + return null; + } + + /** + * Leave the class or function a node declares. + */ + public function leaveNode(Node $node): null + { + if ($node instanceof ClassLike) { + array_pop($this->classes); + } elseif ($node instanceof ClassMethod || $node instanceof Function_) { + array_pop($this->functions); + } + + return null; + } +} diff --git a/src/phpstan/src/Support/EnvironmentSnapshot.php b/src/phpstan/src/Support/EnvironmentSnapshot.php new file mode 100644 index 0000000000..0e5a634730 --- /dev/null +++ b/src/phpstan/src/Support/EnvironmentSnapshot.php @@ -0,0 +1,150 @@ +, env: array, process: array} + */ + protected static ?array $captured = null; + + /** + * Capture the variables before the application boots. + */ + public static function capture(): void + { + static::$captured ??= ['server' => $_SERVER, 'env' => $_ENV, 'process' => getenv()]; + } + + /** + * Get the value of a variable as the environment repository reads it, or null when it is not set. + */ + public static function get(string $name): ?string + { + [$server, $env, $process] = static::variables(); + + return static::scalar($server, $name) ?? static::scalar($env, $name) ?? $process[$name] ?? null; + } + + /** + * Get a digest of the named variables and of every variable matching one of the patterns. + * + * Each variable is digested as each default adapter reads it (ServerConstAdapter, + * EnvConstAdapter, PutenvAdapter), keeping an unset variable distinct from an empty one, + * so the digest changes whichever adapters the application enables. + * + * @param list $names + * @param list $patterns fnmatch() patterns matched against the set variable names + */ + public static function digest(array $names, array $patterns): string + { + [$server, $env, $process] = static::variables(); + + $selected = []; + + foreach ($names as $name) { + $selected[$name] = true; + } + + if ($patterns !== []) { + // $_SERVER also holds PHP's request metadata, such as REQUEST_TIME, which is not + // environment input; process variables reach it from getenv(). Patterns also skip + // PHPRC, which PHPStan's restart points at a new temporary ini file on every run: it + // sets PHP up rather than configuring the application. A named read still tracks it. + foreach (array_keys($process + $env) as $name) { + if ($name === 'PHPRC') { + continue; + } + + foreach ($patterns as $pattern) { + if (fnmatch($pattern, (string) $name)) { + $selected[(string) $name] = true; + + break; + } + } + } + } + + ksort($selected); + + $lines = []; + + foreach (array_keys($selected) as $name) { + $lines[] = $name . '=' . json_encode( + [static::scalar($server, $name), static::scalar($env, $name), $process[$name] ?? null], + JSON_THROW_ON_ERROR, + ); + } + + return hash('xxh128', implode("\n", $lines)); + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$captured = null; + } + + /** + * Get the captured variables, or the current ones before anything has booted. + * + * @return array{array, array, array} + */ + protected static function variables(): array + { + if (static::$captured !== null) { + return [static::$captured['server'], static::$captured['env'], static::$captured['process']]; + } + + return [$_SERVER, $_ENV, getenv()]; + } + + /** + * Read a variable from a superglobal as dotenv's constant adapters do. + * + * @param array $variables + */ + protected static function scalar(array $variables, string $name): ?string + { + $value = $variables[$name] ?? null; + + if (! is_scalar($value)) { + return null; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + return (string) $value; + } +} diff --git a/src/phpstan/src/Support/EnvironmentVariableScanner.php b/src/phpstan/src/Support/EnvironmentVariableScanner.php new file mode 100644 index 0000000000..2222062701 --- /dev/null +++ b/src/phpstan/src/Support/EnvironmentVariableScanner.php @@ -0,0 +1,311 @@ + ['MYSQL_*', 'MARIADB_*', 'POSTGRES_*'], + EnsuresDefaultConfiguration::class . '::bootstrap' => ['APP_KEY', 'APP_DEBUG', 'DB_CONNECTION'], + Application::class . '::normalizeCachePath' => ['APP_*_CACHE'], + ParallelTesting::class . '::option' => ['HYPERVEL_PARALLEL_TESTING_*'], + 'Hypervel\Testbench\defined_environment_variables' => [], + ]; + + /** + * The lowercased names of Env and its subclasses loaded at boot, during a scan. + * + * @var array + */ + private array $envClasses = []; + + /** + * Create a new scanner instance. + */ + public function __construct( + // Resolves names and keeps function bodies, which the cleaning parser drops, without the + // rich parser's analysis visitors, which double the parse time. + #[AutowiredParameter(ref: '@currentPhpVersionSimpleDirectParser')] + private Parser $parser, + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Get the variable names and name patterns the files can read. + * + * The patterns of the modeled framework readers are included whenever their class was loaded + * at boot, scanned or not. A '*' pattern selects every variable. + * + * @param list $files + * @param array $classes the lowercased names of the classes, interfaces and traits loaded at boot + * @return array{names: list, patterns: list} + */ + public function scan(array $files, array $classes): array + { + $patterns = []; + + foreach (self::MODELED_READERS as $method => $readerPatterns) { + if (isset($classes[strtolower(explode('::', $method)[0])])) { + foreach ($readerPatterns as $pattern) { + $patterns[$pattern] = true; + } + } + } + + $this->envClasses = [strtolower(Env::class) => true]; + $subclassNames = []; + + foreach (array_keys($classes) as $class) { + if (is_subclass_of($class, Env::class)) { + $this->envClasses[$class] = true; + $subclassNames[] = preg_quote(class_basename($class), '/'); + } + } + + // Every recognized read names a reader (env(), getenv(), an Env class) or a superglobal. + $candidate = '/\$_SERVER\b|env(?:_array|_or_fail)?\b' . ($subclassNames === [] ? '' : '|\b(?:' . implode('|', $subclassNames) . ')\b') . '/i'; + $visitor = new EnvironmentReadVisitor($this); + $traverser = new NodeTraverser($visitor); + + foreach ($files as $file) { + if (preg_match($candidate, (string) file_get_contents($file)) !== 1) { + continue; + } + + try { + $statements = $this->parser->parseFile($file); + } catch (ParserErrorsException) { + return ['names' => [], 'patterns' => ['*']]; + } + + $traverser->traverse($statements); + + if ($visitor->dynamic) { + return ['names' => [], 'patterns' => ['*']]; + } + } + + return ['names' => array_keys($visitor->names), 'patterns' => array_keys($patterns)]; + } + + /** + * Get the variable a node reads: its name, '' when the name is not constant, or null when it reads none. + * + * @param null|string $class the class enclosing the node + */ + public function readName(Node $node, ?string $class): ?string + { + if ($node instanceof ArrayDimFetch + && $node->var instanceof Variable + && in_array($node->var->name, ['_ENV', '_SERVER'], true)) { + return $node->dim instanceof String_ ? $node->dim->value : ''; + } + + if ($node instanceof FuncCall && $node->name instanceof Name && $this->isReaderFunction($node->name)) { + return $this->argumentName($node, ['key', 'name']); + } + + if ($node instanceof StaticCall + && $node->class instanceof Name + && $node->name instanceof Identifier + && $this->isEnv($node->class, $class)) { + if ($node->name->toLowerString() === 'getrepository') { + return null; + } + + return in_array($node->name->toLowerString(), self::READER_METHODS, true) + ? $this->argumentName($node, ['key']) + : null; + } + + // Env::getRepository()->get($name) reads through the repository directly. + if ($node instanceof MethodCall + && $node->name instanceof Identifier + && $node->name->toLowerString() === 'get' + && $node->var instanceof StaticCall + && $node->var->class instanceof Name + && $node->var->name instanceof Identifier + && $node->var->name->toLowerString() === 'getrepository' + && $this->isEnv($node->var->class, $class)) { + return $this->argumentName($node, ['name']); + } + + return null; + } + + /** + * Determine whether a computed name in the given function is accounted for. + * + * The readers pass their caller's name through, and the modeled framework methods read + * names their patterns cover. + */ + public function readsComputedNames(?string $class, ?string $function): bool + { + if ($class !== null && $this->isEnvClass($class)) { + return true; + } + + if ($class === null && $function !== null && in_array(strtolower($function), self::READER_FUNCTIONS, true)) { + return true; + } + + return $function !== null && isset(self::MODELED_READERS[$function]); + } + + /** + * Determine whether a function name resolves to a global environment reader. + */ + private function isReaderFunction(Name $name): bool + { + if (! in_array($name->toLowerString(), self::READER_FUNCTIONS, true)) { + return false; + } + + // An unqualified call in a namespace runs that namespace's function when one exists. + $namespacedName = $name->getAttribute('namespacedName'); + + return ! $namespacedName instanceof Name || ! function_exists($namespacedName->toString()); + } + + /** + * Determine whether a class name is the Env class or a subclass, where self, static and parent + * name one when the enclosing class is one. + */ + private function isEnv(Name $name, ?string $class): bool + { + if ($name->isSpecialClassName()) { + return $class !== null && $this->isEnvClass($class); + } + + return $this->isEnvClass($name->toString()); + } + + /** + * Determine whether a class is the Env class or a subclass loaded at boot. + */ + private function isEnvClass(string $class): bool + { + return isset($this->envClasses[strtolower($class)]); + } + + /** + * Get the constant variable name a reader call passes, or '' when it is not constant. + * + * @param list $parameterNames the reader's name parameter + */ + private function argumentName(CallLike $call, array $parameterNames): string + { + // A first-class callable, such as env(...), can later read any name. + if ($call->isFirstClassCallable()) { + return ''; + } + + $argument = null; + + foreach ($call->getArgs() as $index => $candidate) { + if ($candidate->unpack) { + return ''; + } + + if ($candidate->name === null ? $index === 0 : in_array($candidate->name->toString(), $parameterNames, true)) { + $argument = $candidate; + + break; + } + } + + return $argument === null ? '' : $this->constantString($argument->value); + } + + /** + * Get the value of a constant string expression, or '' when it is not one. + */ + private function constantString(Expr $expression): string + { + if ($expression instanceof String_) { + return $expression->value; + } + + if ($expression instanceof ClassConstFetch + && $expression->class instanceof Name + && $expression->name instanceof Identifier + && $this->reflectionProvider->hasClass($expression->class->toString())) { + $class = $this->reflectionProvider->getClass($expression->class->toString()); + + if ($class->hasConstant($expression->name->toString())) { + $values = $class->getConstant($expression->name->toString())->getValueType()->getConstantStrings(); + + return count($values) === 1 ? $values[0]->getValue() : ''; + } + } + + return ''; + } +} diff --git a/src/phpstan/src/Support/HigherOrderCollectionProxyHelper.php b/src/phpstan/src/Support/HigherOrderCollectionProxyHelper.php new file mode 100644 index 0000000000..ba01309313 --- /dev/null +++ b/src/phpstan/src/Support/HigherOrderCollectionProxyHelper.php @@ -0,0 +1,161 @@ +getTargetType($classReflection); + + if ($targetType === null) { + return false; + } + + // The proxy reaches only public members, since it runs outside the target's class. + if ($propertyOrMethod === 'property') { + // The proxy reads array items by key. + return $targetType->isArray()->yes() + ? $targetType->hasOffsetValueType(new ConstantStringType($name))->yes() + : $targetType->hasInstanceProperty($name)->yes() && $targetType->getInstanceProperty($name, new OutOfClassScope)->isPublic(); + } + + // The proxy calls the static method of class-string items. + if ($targetType->isClassString()->yes()) { + $classType = $targetType->getClassStringObjectType(); + + if (! $classType->hasMethod($name)->yes()) { + return false; + } + + $method = $classType->getMethod($name, new OutOfClassScope); + + return $method->isStatic() && $method->isPublic(); + } + + return $targetType->hasMethod($name)->yes() && $targetType->getMethod($name, new OutOfClassScope)->isPublic(); + } + + /** + * Get the property read through the proxy. + */ + public function getProperty(ClassReflection $classReflection, string $name): PropertyReflection + { + // hasProperty() accepts only proxies with a target. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + + if ($targetType->isArray()->yes()) { + return new ProxiedPropertyReflection( + $classReflection, + $this->getReturnType($classReflection, $targetType->getOffsetValueType(new ConstantStringType($name))), + null, + ); + } + + $targetProperty = $targetType->getInstanceProperty($name, new OutOfClassScope); + + return new ProxiedPropertyReflection($classReflection, $this->getReturnType($classReflection, $targetProperty->getReadableType()), $targetProperty); + } + + /** + * Get the method called through the proxy. + */ + public function getMethod(ClassReflection $classReflection, string $name): MethodReflection + { + // hasMethod() accepts only proxies with a target. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + + if ($targetType->isClassString()->yes()) { + $targetType = $targetType->getClassStringObjectType(); + } + + return new ProxiedMethodReflection( + $classReflection, + $targetType->getMethod($name, new OutOfClassScope), + fn (Type $returnType): Type => $this->getReturnType($classReflection, $returnType), + ); + } + + /** + * Get what the proxied collection method returns for a callback returning the given type. + */ + private function getReturnType(ClassReflection $classReflection, Type $callbackReturnType): Type + { + $templateTypeMap = $classReflection->getActiveTemplateTypeMap(); + + // getTargetType() accepts only proxies with these templates. + /** @var Type $methodType */ + $methodType = $templateTypeMap->getType('TMethod'); + + /** @var Type $collectionType */ + $collectionType = $templateTypeMap->getType('TCollection'); + + return ParametersAcceptorSelector::selectFromTypes( + [new ClosureType([], $callbackReturnType, false)], + $collectionType->getMethod($methodType->getConstantStrings()[0]->getValue(), new OutOfClassScope)->getVariants(), + false, + )->getReturnType(); + } + + /** + * Get the type the proxy calls members on: the collection items, or the collection itself + * for when() and unless(), which pass the collection to the callback. + */ + private function getTargetType(ClassReflection $classReflection): ?Type + { + if ($classReflection->getName() !== HigherOrderCollectionProxy::class) { + return null; + } + + $templateTypeMap = $classReflection->getActiveTemplateTypeMap(); + $methodType = $templateTypeMap->getType('TMethod'); + $valueType = $templateTypeMap->getType('TValue'); + $collectionType = $templateTypeMap->getType('TCollection'); + + if ($methodType === null || $valueType === null || $collectionType === null || count($methodType->getConstantStrings()) !== 1) { + return null; + } + + $method = $methodType->getConstantStrings()[0]->getValue(); + + if (! $collectionType->hasMethod($method)->yes()) { + return null; + } + + return in_array($method, ['when', 'unless'], true) ? $collectionType : $valueType; + } +} diff --git a/src/phpstan/src/Support/HigherOrderWhenProxyHelper.php b/src/phpstan/src/Support/HigherOrderWhenProxyHelper.php new file mode 100644 index 0000000000..318c86e9c1 --- /dev/null +++ b/src/phpstan/src/Support/HigherOrderWhenProxyHelper.php @@ -0,0 +1,115 @@ +getTargetType($classReflection); + + if ($targetType === null) { + return false; + } + + // The proxy reaches only public members, since it runs outside the target's class. + return $propertyOrMethod === 'method' + ? $targetType->hasMethod($name)->yes() && $targetType->getMethod($name, new OutOfClassScope)->isPublic() + : $targetType->hasInstanceProperty($name)->yes() && $targetType->getInstanceProperty($name, new OutOfClassScope)->isPublic(); + } + + /** + * Get the property read through the proxy. + */ + public function getProperty(ClassReflection $classReflection, string $name): PropertyReflection + { + // hasProperty() accepts only proxies with a target. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + $targetProperty = $targetType->getInstanceProperty($name, new OutOfClassScope); + + return new ProxiedPropertyReflection($classReflection, $this->getReturnType($classReflection, $targetProperty->getReadableType()), $targetProperty); + } + + /** + * Get the method called through the proxy. + */ + public function getMethod(ClassReflection $classReflection, string $name): MethodReflection + { + // hasMethod() accepts only proxies with a target. + /** @var Type $targetType */ + $targetType = $this->getTargetType($classReflection); + + return new ProxiedMethodReflection( + $classReflection, + $targetType->getMethod($name, new OutOfClassScope), + fn (Type $returnType): Type => $this->getReturnType($classReflection, $returnType), + ); + } + + /** + * Get what the proxy returns for a member of the given type. + */ + private function getReturnType(ClassReflection $classReflection, Type $memberType): Type + { + $templateTypeMap = $classReflection->getActiveTemplateTypeMap(); + + // getTargetType() accepts only proxies with a target. + /** @var Type $targetType */ + $targetType = $templateTypeMap->getType('TTarget'); + $hasCondition = $templateTypeMap->getType('THasCondition') ?? new BooleanType; + $types = []; + + if (! $hasCondition->isTrue()->yes()) { + $types[] = new GenericObjectType(HigherOrderWhenProxy::class, [$targetType, new ConstantBooleanType(true)]); + } + + if (! $hasCondition->isFalse()->yes()) { + $types[] = $memberType; + $types[] = $targetType; + } + + return TypeCombinator::union(...$types); + } + + /** + * Get the object the proxy passes members to. + */ + private function getTargetType(ClassReflection $classReflection): ?Type + { + if ($classReflection->getName() !== HigherOrderWhenProxy::class) { + return null; + } + + $targetType = $classReflection->getActiveTemplateTypeMap()->getType('TTarget'); + + return $targetType !== null && $targetType->isObject()->yes() ? $targetType : null; + } +} diff --git a/src/phpstan/src/Support/InstanceNames.php b/src/phpstan/src/Support/InstanceNames.php new file mode 100644 index 0000000000..09387750c5 --- /dev/null +++ b/src/phpstan/src/Support/InstanceNames.php @@ -0,0 +1,50 @@ + + */ + public static function fromArgument(?Arg $name, Scope $scope): ?array + { + if ($name === null) { + return [null]; + } + + if ($name->unpack) { + return null; + } + + $names = []; + + foreach ($scope->getType($name->value)->getFiniteTypes() as $type) { + if ($type->isNull()->yes()) { + $names[] = null; + } elseif ($type->getConstantStrings() !== []) { + $names[] = $type->getConstantStrings()[0]->getValue(); + } elseif ($type->getEnumCases() !== []) { + // enum_value() resolves a backed case to its value and a pure case to its name. + $enumCase = $type->getEnumCases()[0]; + $names[] = (string) ($enumCase->getBackingValueType()?->getConstantScalarValues()[0] ?? $enumCase->getEnumCaseName()); + } else { + return null; + } + } + + return $names === [] ? null : $names; + } +} diff --git a/src/phpstan/src/Support/RecursiveDirectoryIterator.php b/src/phpstan/src/Support/RecursiveDirectoryIterator.php new file mode 100644 index 0000000000..07c2c7caf2 --- /dev/null +++ b/src/phpstan/src/Support/RecursiveDirectoryIterator.php @@ -0,0 +1,90 @@ +isLink() || ! $file->isDir()) { + return true; + } + + for ($path = $file->getPath(); realpath($path) !== $file->getRealPath(); $path = dirname($path)) { + if (strlen($path) <= strlen($directory)) { + return true; + } + } + + return false; + }, + ); + } + + /** + * Move to the next entry. + */ + public function next(): void + { + $this->ignoreFirstRewind = false; + + parent::next(); + } + + /** + * Rewind to the first entry, except on the first call. + */ + public function rewind(): void + { + if ($this->ignoreFirstRewind) { + $this->ignoreFirstRewind = false; + + return; + } + + parent::rewind(); + } +} diff --git a/src/phpstan/src/Support/RuntimeInputs.php b/src/phpstan/src/Support/RuntimeInputs.php new file mode 100644 index 0000000000..c7efdbd59e --- /dev/null +++ b/src/phpstan/src/Support/RuntimeInputs.php @@ -0,0 +1,695 @@ + + */ + protected static array $includedFiles = []; + + /** + * The lowercased names of the classes, interfaces and traits loaded once the application had booted. + * + * Analysis loads more classes, which would make keys differ between processes. + * + * @var array + */ + protected static array $classes = []; + + /** + * The files declaring the classes, interfaces and traits loaded once the application had booted. + * + * @var array + */ + protected static array $classFiles = []; + + /** + * The environment file an application boot selects from, or null for a Testbench package, + * whose application loads no environment file. + */ + protected static ?string $environmentFile = null; + + /** + * Whether the analyzer booted a Testbench application for a package rather than an application's own. + */ + protected static bool $packageApplication = false; + + /** + * The value-dependency key, built on first use. + */ + private ?string $key = null; + + /** + * The value-dependency key of the view directories' file names, built on first use. + */ + private ?string $viewsKey = null; + + /** + * The owned runtime copy and the skeleton it was made from, or false when there is none. + * + * @var null|array{string, string}|false + */ + private array|false|null $runtimeCopy = null; + + /** + * The tracked path for each runtime path. + * + * @var array + */ + private array $sourcePaths = []; + + /** + * The tracked source files of each class below a base class. + * + * @var array> + */ + private array $classSources = []; + + /** + * Create a new runtime inputs instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private DeclarationDependencyTracker $declarationDependencyTracker, + private EnvironmentVariableScanner $environmentVariableScanner, + private ConfigSourceResolver $configSourceResolver, + private RuntimeInputsResultCacheValueExtension $valueExtension, + #[AutowiredParameter(ref: '%tmpDir%')] + private string $tmpDir, + ) { + } + + /** + * Capture what the boot loaded. + * + * Called by the analyzer's bootstrap file once the application has booted, before + * analysis loads anything else. + */ + public static function captureBoot(Application $app, ?string $environmentFile, bool $packageApplication): void + { + static::$application = $app; + static::$includedFiles = get_included_files(); + static::$environmentFile = $environmentFile; + static::$packageApplication = $packageApplication; + + foreach ([...get_declared_classes(), ...get_declared_interfaces(), ...get_declared_traits()] as $class) { + static::$classes[strtolower($class)] = true; + $file = (new ReflectionClass($class))->getFileName(); + + if ($file !== false) { + static::$classFiles[$file] = true; + } + } + } + + /** + * Determine whether the analyzer booted a Testbench application for a package. + */ + public function isPackageApplication(): bool + { + return static::$packageApplication; + } + + /** + * Declare that the analysis of the current file, or what a class declares, depends on the booted application. + * + * @param ClassReflection|(DependencyTracker&Scope) $dependent + */ + public function track(Scope|ClassReflection $dependent): void + { + if ($dependent instanceof ClassReflection) { + $this->declarationDependencyTracker->trackValueDependency($dependent, RuntimeInputsResultCacheValueExtension::class, $this->key()); + } else { + $dependent->trackValueDependency(RuntimeInputsResultCacheValueExtension::class, $this->key()); + } + } + + /** + * Declare that the dependent depends on what a class the booted application selected declares. + * + * Resolving a binding can load its class after boot, outside the runtime inputs, and a + * declared type is read without loading its class at all. + * + * @param ClassReflection|(DependencyTracker&Scope) $dependent + */ + public function trackClass(Scope|ClassReflection $dependent, string $className): void + { + if ($dependent instanceof ClassReflection) { + $this->declarationDependencyTracker->trackClassDependency($dependent, $className); + } else { + $dependent->trackClassDependency($className); + } + } + + /** + * Declare that the dependent depends on the declarations and code of a class the booted application selected and the analysis runs. + * + * Method bodies and property defaults are not declarations, so the files declaring the class and its + * parents below the base class, with their traits and the constants their defaults read, are tracked too. + * + * @param ClassReflection|(DependencyTracker&Scope) $dependent + */ + public function trackSources(Scope|ClassReflection $dependent, string $className, string $baseClass): void + { + $this->trackClass($dependent, $className); + + $files = $this->classSources[$className . "\0" . $baseClass] ??= array_map( + $this->sourcePath(...), + SourceFiles::forInstance($this->reflectionProvider, $this->reflectionProvider->getClass($className), $baseClass), + ); + + foreach ($files as $file) { + if ($dependent instanceof ClassReflection) { + $this->declarationDependencyTracker->trackFileDependency($dependent, $file); + } else { + $dependent->trackFileDependency($file); + } + } + } + + /** + * Declare that the dependent depends on which views exist. + * + * Only the names of the files in the booted finder's directories matter, so editing a + * template does not invalidate it. The finder's directories come from the boot. + * + * @param ClassReflection|(DependencyTracker&Scope) $dependent + */ + public function trackViews(Scope|ClassReflection $dependent): void + { + $this->track($dependent); + + $this->viewsKey ??= $this->createViewsKey(); + + if ($dependent instanceof ClassReflection) { + $this->declarationDependencyTracker->trackValueDependency($dependent, RuntimeInputsResultCacheValueExtension::class, $this->viewsKey); + } else { + $dependent->trackValueDependency(RuntimeInputsResultCacheValueExtension::class, $this->viewsKey); + } + } + + /** + * Declare that the analysis of the current file depends on the names and contents of the files in directories, as the view finder and translation loader read them. + * + * Both find files through symlinked directories, which PHPStan's directory dependency does not enter. + * + * @param DependencyTracker&Scope $scope + * @param list $directories + */ + public function trackLinkedDirectories(Scope $scope, array $directories, string $pattern = '*'): void + { + $linkedDirectories = []; + + foreach ($directories as $directory) { + $source = $this->mapRuntimePath( + $this->trimDirectory($directory), + "linked-directory\t" . $pattern, + static fn (string $path): string => RuntimeInputsResultCacheValueExtension::directoryDigest($path, $pattern, followLinks: true), + ); + + $linkedDirectories[$source] = $pattern; + } + + $scope->trackValueDependency(RuntimeInputsResultCacheValueExtension::class, $this->valueExtension->createKey(linkedDirectories: $linkedDirectories)); + } + + /** + * Get the path to track for a file the analysis reads. + * + * A file in the runtime copy is tracked through its skeleton source while the two are + * identical. A file the copy changed or added has no known source and keeps its runtime + * path, which disappears with the copy, so it is analyzed again on every run. + */ + public function sourcePath(string $path): string + { + return $this->mapRuntimePath($path, 'file', RuntimeInputsResultCacheValueExtension::fileDigest(...)); + } + + /** + * Get the directory to track for files in it the analysis reads, as sourcePath() does for a file. + */ + public function sourceDirectory(string $directory, string $pattern = '*'): string + { + return $this->mapRuntimePath( + $directory, + "directory\t" . $pattern, + static fn (string $path): string => RuntimeInputsResultCacheValueExtension::directoryDigest($path, $pattern), + ); + } + + /** + * Get the value-dependency key, built once per process. + */ + private function key(): string + { + return $this->key ??= $this->createKey(); + } + + /** + * Create the value-dependency key of the names of the files in the booted view finder's directories. + */ + private function createViewsKey(): string + { + $finder = $this->application()->make('view')->getFinder(); + $directories = $finder->getPaths(); + + if ($finder instanceof FileViewFinder) { + foreach ($finder->getHints() as $paths) { + $directories = [...$directories, ...$paths]; + } + } + + $listings = []; + + foreach ($directories as $directory) { + $listings[] = $this->mapRuntimePath($this->trimDirectory($directory), 'listing', RuntimeInputsResultCacheValueExtension::listingDigest(...)); + } + + return $this->valueExtension->createKey(listings: $listings); + } + + /** + * Create the value-dependency key from what the boot loaded. + */ + private function createKey(): string + { + $app = $this->application(); + $analyzerPath = (string) realpath(dirname(__DIR__, 2)); + $tmpPath = realpath($this->tmpDir) ?: $this->tmpDir; + $rootPath = (string) realpath(Composer::rootPackage()['install_path']); + $vendorPath = realpath(Composer::vendorPath($rootPath)) ?: Composer::vendorPath($rootPath); + $pathPackages = $this->pathPackageDirectories($vendorPath); + [$providerPackages, $providerFiles] = $this->providerSources($app, $vendorPath); + + $files = []; + $scannedFiles = []; + + foreach (static::$includedFiles as $file) { + // PHPStan's own files. Its compiled container, in its temporary directory, embeds the + // run's environment, such as the restart's random PHPRC path, whenever it is recompiled. + if (str_starts_with($file, 'phar://') || str_starts_with($file, $analyzerPath . '/') || str_starts_with($file, $tmpPath . '/')) { + continue; + } + + $package = str_starts_with($file, $vendorPath . '/') ? $this->packageDirectory($file, $vendorPath) : null; + + // A versioned install changes Composer's installed metadata, tracked below, so its files + // are not hashed. Its scripts, such as framework config files and helpers, still read + // environment variables, and so can the registration code of a package whose provider + // the boot ran. Its other classes read the environment at run time, if at all. + if ($package !== null && ! isset($pathPackages[$package])) { + if (! isset(static::$classFiles[$file]) || isset($providerPackages[$package]) || isset($providerFiles[$file])) { + $scannedFiles[] = $file; + } + + continue; + } + + $files[] = $file; + $scannedFiles[] = $file; + } + + foreach (array_keys([$vendorPath => true, $app->make(PackageManifest::class)->vendorPath => true]) as $vendor) { + $files[] = $vendor . '/composer/installed.json'; + $files[] = $vendor . '/composer/installed.php'; + } + + $files[] = $app->basePath('composer.json'); + $files[] = $app->bootstrapPath('providers.php'); + $files = array_merge($files, $this->environmentFiles($app), $this->testbenchConfigurationFiles()); + + $outputs = [$app->getCachedConfigPath(), $app->getCachedPackagesPath(), $app->getCachedRoutesPath(), $app->getCachedEventsPath()]; + + // A cache file the boot loaded is listed by its resolved path, so either spelling identifies it. + $generated = []; + + foreach ($outputs as $output) { + $generated[$output] = true; + $generated[realpath($output) ?: $output] = true; + } + + $trackedFiles = []; + + // Files keep their own paths: a key holding a symlink's target would miss the link being pointed elsewhere. + foreach ([...$files, ...$outputs] as $file) { + $source = $this->sourcePath($file); + + // The boot writes these caches into the runtime copy from the inputs tracked here. + if ($source === $file && isset($generated[$file]) && $this->isInRuntimeCopy($file)) { + continue; + } + + $trackedFiles[] = $source; + } + + $directories = []; + + // The boot reads the configuration files only while configuration is not cached. + foreach ($app->configurationIsCached() ? [] : $this->configSourceResolver->getBootedConfigDirectories() as $directory) { + $directories[$this->sourceDirectory($directory, ConfigSourceResolver::CONFIG_FILE_PATTERN)] = ConfigSourceResolver::CONFIG_FILE_PATTERN; + } + + $variables = $this->environmentVariableScanner->scan($scannedFiles, static::$classes); + + return $this->valueExtension->createKey( + files: $trackedFiles, + presenceFiles: $this->presenceFiles($app), + directories: $directories, + variables: [...$variables['names'], ...$this->interpolatedVariables($app)], + variablePatterns: $variables['patterns'], + ); + } + + /** + * Get the directories of the path-repository packages Composer mirrored into the vendor directory rather than linked. + * + * @return array + */ + private function pathPackageDirectories(string $vendorPath): array + { + $installed = json_decode((string) file_get_contents($vendorPath . '/composer/installed.json'), true, flags: JSON_THROW_ON_ERROR); + $directories = []; + + foreach ($installed['packages'] as $package) { + if (($package['dist']['type'] ?? null) === 'path') { + $directories[(string) realpath($vendorPath . '/composer/' . $package['install-path'])] = true; + } + } + + return $directories; + } + + /** + * Get the vendor package directories that supply a loaded provider, and the files of every + * loaded provider with its parents and traits. + * + * @return array{array, array} + */ + private function providerSources(Application $app, string $vendorPath): array + { + $pending = array_keys($app->getLoadedProviders()); + $packages = []; + $files = []; + $seen = []; + + foreach ($pending as $provider) { + $file = (string) (new ReflectionClass($provider))->getFileName(); + + if (str_starts_with($file, $vendorPath . '/')) { + $packages[$this->packageDirectory($file, $vendorPath)] = true; + } + } + + while (($class = array_pop($pending)) !== null) { + if (isset($seen[$class])) { + continue; + } + + $seen[$class] = true; + $reflection = new ReflectionClass($class); + $files[(string) $reflection->getFileName()] = true; + $parent = $reflection->getParentClass(); + $pending = [...$pending, ...$reflection->getTraitNames(), ...($parent === false ? [] : [$parent->getName()])]; + } + + return [$packages, $files]; + } + + /** + * Get the directory of the vendor package a vendor file belongs to. + * + * Composer installs every package two levels below the vendor directory. + */ + private function packageDirectory(string $file, string $vendorPath): string + { + $segments = explode('/', substr($file, strlen($vendorPath) + 1), 3); + + return $vendorPath . '/' . $segments[0] . '/' . ($segments[1] ?? ''); + } + + /** + * Determine whether the boot loaded an environment file. + * + * A Testbench package's application loads none, and LoadEnvironmentVariables skips the + * file while configuration is cached, so the tracked configuration cache decides instead. + */ + private function loadsEnvironmentFile(Application $app): bool + { + return static::$environmentFile !== null && ! $app->configurationIsCached(); + } + + /** + * Get the environment files an application boot can load. + * + * LoadEnvironmentVariables loads the file named for the incoming APP_ENV when it exists, + * else the configured file. + * + * @return list + */ + private function environmentFiles(Application $app): array + { + if (! $this->loadsEnvironmentFile($app)) { + return []; + } + + $files = [static::$environmentFile, $app->environmentFilePath()]; + $environment = EnvironmentSnapshot::get('APP_ENV'); + + if ($environment !== null && $environment !== '') { + $files[] = static::$environmentFile . '.' . $environment; + } + + return $files; + } + + /** + * Get the variables the loaded environment file interpolates, such as DEPLOY_QUEUE in QUEUE_CONNECTION=${DEPLOY_QUEUE}. + * + * @return list + */ + private function interpolatedVariables(Application $app): array + { + if (! $this->loadsEnvironmentFile($app)) { + return []; + } + + $file = $app->environmentFilePath(); + + if (! is_file($file)) { + return []; + } + + $variables = []; + + foreach ((new DotenvParser)->parse((string) file_get_contents($file)) as $entry) { + if ($entry->getValue()->isEmpty()) { + continue; + } + + $value = $entry->getValue()->get(); + + foreach ($value->getVars() as $position) { + // The pattern dotenv's Resolver replaces. + if (preg_match('/\A\${([a-zA-Z0-9_.]+)}/', substr($value->getChars(), $position), $matches) === 1) { + $variables[] = $matches[1]; + } + } + } + + return $variables; + } + + /** + * Get the Testbench configuration files a package boot reads. + * + * The loaded configuration names its directory: an earlier bootstrap in the process + * may have loaded it from elsewhere than the analyzer asked for. + * + * @return list + */ + private function testbenchConfigurationFiles(): array + { + if (! static::$packageApplication) { + return []; + } + + $configuration = Bootstrapper::getConfiguration(); + $path = $configuration instanceof TestbenchConfig ? $configuration->getConfigurationPath() : null; + + return $path === null ? [] : [$path . '/testbench.yaml', $path . '/testbench.yaml.example', $path . '/testbench.yaml.dist']; + } + + /** + * Get the files whose presence the boot checks. + * + * Testbench's configuration loader makes the testing connection the default when the + * configured SQLite database file is missing, so the file matters whatever the default + * is now. Only whether it exists matters; the database is never read. + * + * @return list + */ + private function presenceFiles(Application $app): array + { + if (! $app->make(LoadConfiguration::class) instanceof TestbenchLoadConfiguration) { + return []; + } + + $database = $app->make('config')->get('database.connections.sqlite.database'); + + if (! is_string($database) || SQLiteDatabase::isInMemory($database) || SQLiteDatabase::isUri($database)) { + return []; + } + + // The copy is taken just before the boot checks the file, so the skeleton's file decides. + $runtimeCopy = $this->runtimeCopy(); + + if ($runtimeCopy !== false && str_starts_with($database, $runtimeCopy[0] . '/')) { + return [$runtimeCopy[1] . substr($database, strlen($runtimeCopy[0]))]; + } + + return [$database]; + } + + /** + * Map a path in the runtime copy to its skeleton source when both have the same digest. + * + * @param string $comparison what the digest compares, which separates the memoized results + * @param callable(string): string $digest + */ + private function mapRuntimePath(string $path, string $comparison, callable $digest): string + { + $runtimeCopy = $this->runtimeCopy(); + + if ($runtimeCopy === false || ! str_starts_with($path, $runtimeCopy[0] . '/')) { + return $path; + } + + $source = $runtimeCopy[1] . substr($path, strlen($runtimeCopy[0])); + + return $this->sourcePaths[$comparison . "\0" . $path] ??= $digest($path) === $digest($source) ? $source : $path; + } + + /** + * Get a directory path without trailing separators, which would shift the digested relative names. + * + * A symlinked directory keeps its own path rather than its target's, so the digest follows the link + * on every run and notices a link pointed somewhere else. + */ + private function trimDirectory(string $directory): string + { + $trimmed = rtrim($directory, '/'); + + return $trimmed === '' ? '/' : $trimmed; + } + + /** + * Determine whether a path is in the owned runtime copy. + */ + private function isInRuntimeCopy(string $path): bool + { + $runtimeCopy = $this->runtimeCopy(); + + return $runtimeCopy !== false && str_starts_with($path, $runtimeCopy[0] . '/'); + } + + /** + * Get the realpaths of the runtime copy this process owns and of the skeleton it was made from. + * + * @return array{string, string}|false + */ + private function runtimeCopy(): array|false + { + if ($this->runtimeCopy !== null) { + return $this->runtimeCopy; + } + + if (! class_exists(Bootstrapper::class)) { + return $this->runtimeCopy = false; + } + + $basePath = $this->application()->basePath(); + $sourcePath = Bootstrapper::runtimeSourcePath(); + + if ($sourcePath === null || ! Bootstrapper::ownsRuntimePath($basePath)) { + return $this->runtimeCopy = false; + } + + return $this->runtimeCopy = [(string) realpath($basePath), (string) realpath($sourcePath)]; + } + + /** + * Get the booted application. + */ + private function application(): Application + { + return static::$application ?? throw new LogicException('The analyzer bootstrap file did not capture the booted application.'); + } +} diff --git a/src/phpstan/src/Support/ViewFileHelper.php b/src/phpstan/src/Support/ViewFileHelper.php new file mode 100644 index 0000000000..87e452ae46 --- /dev/null +++ b/src/phpstan/src/Support/ViewFileHelper.php @@ -0,0 +1,202 @@ + + */ + private array $viewDirectories; + + /** + * The directories whose views can be reported as unused, named relative to the directory. + * + * @var list + */ + private array $namedViewDirectories; + + /** + * Create a new view file helper instance. + * + * Configured directories replace the booted finder's paths and namespace hints for + * the unused-view rule; the missing-translation rule always scans the finder's paths, + * as upstream does. Upstream also names the views in namespace hint directories as + * though they were root views. Those views are only scanned for usages here: framework + * and package views, such as pagination::tailwind, are rendered by names that no + * literal call shows. + * + * @param list $viewDirectories + */ + public function __construct( + #[AutowiredParameter] + array $viewDirectories, + private FileHelper $fileHelper, + private RuntimeInputs $runtimeInputs, + ) { + $hintDirectories = []; + + if ($viewDirectories === []) { + $finder = $this->finder(); + $viewDirectories = $finder->getPaths(); + + // Hypervel's finder contract has no namespace hints; the standard finder does. + if ($finder instanceof FileViewFinder) { + $hintDirectories = array_merge(...array_values($finder->getHints())); + } + } + + // View names are derived from these directories, so a relative directory is + // resolved once to match the paths of the files found beneath it. + $this->namedViewDirectories = array_map($this->fileHelper->absolutizePath(...), $viewDirectories); + $this->viewDirectories = [ + ...$this->namedViewDirectories, + ...array_map($this->fileHelper->absolutizePath(...), $hintDirectories), + ]; + } + + /** + * Track the root view directories. + * + * The result cache runs the rule again when a view file in the root view directories changes. + * + * @param DependencyTracker&Scope $scope + */ + public function trackRootViewDirectories(Scope $scope): void + { + $this->runtimeInputs->track($scope); + $this->runtimeInputs->trackLinkedDirectories($scope, array_map($this->fileHelper->absolutizePath(...), $this->finder()->getPaths()), '*.blade.php'); + } + + /** + * Track every view directory. + * + * The result cache runs the rule again when a view file in any of the view directories changes. + * + * @param DependencyTracker&Scope $scope + */ + public function trackAllViewDirectories(Scope $scope): void + { + $this->runtimeInputs->track($scope); + $this->runtimeInputs->trackLinkedDirectories($scope, $this->viewDirectories, '*.blade.php'); + } + + /** + * Get the view files in the root view directories. + * + * @return Generator + */ + public function getRootViewFilePaths(): Generator + { + foreach ($this->finder()->getPaths() as $path) { + foreach ($this->getViews($path) as $view) { + yield $view->getPathname(); + } + } + } + + /** + * Get the view files in every view directory. + * + * @return Generator + */ + public function getAllViewFilePaths(): Generator + { + foreach ($this->viewDirectories as $viewDirectory) { + foreach ($this->getViews($viewDirectory) as $view) { + yield $view->getPathname(); + } + } + } + + /** + * Get the names of the views that can be reported as unused. + * + * @return Generator + */ + public function getAllViewNames(): Generator + { + foreach ($this->namedViewDirectories as $viewDirectory) { + $prefix = rtrim($viewDirectory, '/\\') . DIRECTORY_SEPARATOR; + + foreach ($this->getViews($viewDirectory) as $view) { + $relativePath = substr($view->getPathname(), strlen($prefix)); + + // Vendor overrides and error pages are rendered through their view namespaces. + // Upstream matches these directory names anywhere in the absolute path. + if (str_starts_with($relativePath, 'vendor' . DIRECTORY_SEPARATOR) || str_starts_with($relativePath, 'errors' . DIRECTORY_SEPARATOR)) { + continue; + } + + yield str_replace([DIRECTORY_SEPARATOR, '.blade.php'], ['.', ''], $relativePath); + } + } + } + + /** + * Get the Blade view files beneath the given directory. + * + * @return SplFileInfo[] + */ + protected function getViews(string $path): array + { + $absolutePath = $this->fileHelper->absolutizePath($path); + + if (! is_dir($absolutePath)) { + return []; + } + + // The view finder finds views through symlinked directories too. + return iterator_to_array( + new RegexIterator( + new RecursiveIteratorIterator(RecursiveDirectoryIterator::followingLinks($absolutePath)), + '/\.blade\.php$/i', + ), + ); + } + + /** + * Get the booted application's view finder. + */ + protected function finder(): ViewFinderInterface + { + /** @var ViewFactory $factory The canonical view binding is the standard factory. */ + $factory = $this->resolve('view'); + + return $factory->getFinder(); + } +} diff --git a/src/phpstan/src/Support/ViewParser.php b/src/phpstan/src/Support/ViewParser.php new file mode 100644 index 0000000000..a8f7ea5aae --- /dev/null +++ b/src/phpstan/src/Support/ViewParser.php @@ -0,0 +1,38 @@ +parser->parseString($code); + } catch (ParserErrorsException) { + return []; + } + } +} diff --git a/src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php b/src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php new file mode 100644 index 0000000000..bb3a0d9382 --- /dev/null +++ b/src/phpstan/src/Types/AbortIfFunctionTypeSpecifyingExtension.php @@ -0,0 +1,76 @@ +methodName = $methodName . '_' . ($negate === false ? 'if' : 'unless'); + } + + /** + * Determine whether the call is a statement-level call of the helper. + */ + public function isFunctionSupported( + FunctionReflection $functionReflection, + FuncCall $node, + TypeSpecifierContext $context, + ): bool { + return $functionReflection->getName() === $this->methodName && $context->null(); + } + + /** + * Narrow the condition to the outcome that lets the call return. + */ + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes { + $args = $node->getArgs(); + + // Hypervel: an unpacked first argument is the whole argument array, not the condition. + if ($args === [] || $args[0]->unpack) { + return new SpecifiedTypes; + } + + if (! str_starts_with($this->methodName, 'throw') && count($node->args) < 2) { + return new SpecifiedTypes; + } + + $context = $this->negate === false ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createTruthy(); + + return $this->typeSpecifier->specifyTypesInCondition($scope, $args[0]->value, $context); + } + + /** + * Set the type specifier used to narrow the condition. + */ + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } +} diff --git a/src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php b/src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php new file mode 100644 index 0000000000..e713f44c5f --- /dev/null +++ b/src/phpstan/src/Types/ArrPullParameterOutTypeExtension.php @@ -0,0 +1,78 @@ +getName() === 'pull' + && $parameter->getName() === 'array' + && $methodReflection->getDeclaringClass()->getName() === Arr::class; + } + + /** + * Get the array type without the pulled key. + */ + public function getParameterOutTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + ParameterReflection $parameter, + Scope $scope, + ): ?Type { + $arrayArg = $methodCall->getArg('array', 0); + $keyArg = $methodCall->getArg('key', 1); + + // pull() removes the key through forget(), which checks keys through accessible() and exists(). + if ($arrayArg === null || $keyArg === null || ! ArrKeyLookup::appliesTo($scope, $methodCall, ['pull', 'forget', 'accessible', 'exists'])) { + return null; + } + + $arrayType = $scope->getType($arrayArg->value); + + if (! $arrayType->isArray()->yes()) { + return null; + } + + $lookups = ArrKeyLookup::find($scope, $arrayArg->value, $keyArg->value); + + if ($lookups === null) { + return null; + } + + foreach ($lookups as $candidates) { + // Arr::forget() may remove a "dot" path instead of the literal key. + if (count($candidates) > 1) { + return null; + } + } + + // unset() on a list mishandles numeric strings like '0', so pass the integer key instead. + return $arrayType->unsetOffset($scope->getType($keyArg->value)->toArrayKey()); + } +} diff --git a/src/phpstan/src/Types/BuilderOf/BuilderOfType.php b/src/phpstan/src/Types/BuilderOf/BuilderOfType.php new file mode 100644 index 0000000000..28ef34be9b --- /dev/null +++ b/src/phpstan/src/Types/BuilderOf/BuilderOfType.php @@ -0,0 +1,225 @@ +, or of a relation path's related + * model, written builder-of. + * + * model-query-of and model-query-of are the builder newModelQuery() creates, which + * the unscoped queries, relationship existence queries and toQuery() run. It differs from newQuery()'s + * when the model overrides newQuery() with another builder. + */ +// @phpstan-ignore phpstanApi.interface (PHPStan's own late-resolvable types are compound, so comparisons resolve them first.) +class BuilderOfType implements CompoundType, LateResolvableType +{ + use LateResolvableTypeTrait; + + /** + * Create a builder-of type. + * + * @param bool $modelQuery whether the type is newModelQuery()'s builder, written model-query-of + */ + public function __construct( + private Type $type, + private BuilderHelper $builderHelper, + private ?Type $relationType = null, + private bool $modelQuery = false, + ) { + } + + /** + * Resolve the builder type of each model the type stands for. + */ + protected function getResult(): Type + { + $relatedType = $this->type; + + if ($this->relationType !== null) { + [$relationType, $unknown] = (new RelationOfType($this->type, $this->relationType))->resolveRelations(); + + // Unlike upstream, a path that does not resolve queries an unknown model, never the declaring one. + $relatedTypes = $relationType === null ? [] : [$relationType->getTemplateType(Relation::class, 'TRelatedModel')]; + + if ($unknown) { + $relatedTypes[] = new ObjectType(Model::class); + } + + $relatedType = TypeCombinator::union(...$relatedTypes); + } + + $results = []; + + foreach (TypeUtils::flattenTypes($relatedType) as $modelType) { + foreach ($modelType->getObjectClassNames() as $className) { + $builderType = $this->builderHelper->determineBuilderClass($className, $modelType, $this->modelQuery); + + if ($builderType === null) { + continue; + } + + $results[] = $builderType; + } + } + + // Unlike upstream, which resolves to never, an unknown model such as a relation's `*` has an unknown + // builder, as relation-of gives an unknown relation. + if ($results === [] && (new ObjectType(Model::class))->isSuperTypeOf($relatedType)->maybe()) { + return new GenericObjectType(EloquentBuilder::class, [new MixedType], null, null, [TemplateTypeVariance::createBivariant()]); + } + + return TypeCombinator::union(...$results); + } + + /** + * Determine whether the type can be resolved, which waits for its template types. + */ + public function isResolvable(): bool + { + return ! TypeUtils::containsTemplateType($this->type) + && ($this->relationType === null || ! TypeUtils::containsTemplateType($this->relationType)); + } + + /** + * Get the classes the type refers to. + * + * @return list + */ + public function getReferencedClasses(): array + { + return array_merge($this->type->getReferencedClasses(), $this->relationType?->getReferencedClasses() ?? []); + } + + /** + * Get the template types the type refers to. + * + * @return list + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + // Unlike upstream, the inner types are invariant, as Builder's model is, so a `*` argument resolves + // as it does there instead of to never in a parameter. + $variance = $positionVariance->compose(TemplateTypeVariance::createInvariant()); + + return array_merge( + $this->type->getReferencedTemplateTypes($variance), + $this->relationType?->getReferencedTemplateTypes($variance) ?? [], + ); + } + + /** + * Determine whether the type equals another. + */ + public function equals(Type $type): bool + { + return $type instanceof self && $this->modelQuery === $type->modelQuery && $this->type->equals($type->type) + && ($this->relationType === null + ? $type->relationType === null + : $type->relationType !== null && $this->relationType->equals($type->relationType)); + } + + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + return $this->name() . '<' . $this->type->describe($level) + . ($this->relationType === null ? '' : ', ' . $this->relationType->describe($level)) . '>'; + } + + /** + * Map the inner types. + * + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $type = $cb($this->type); + $relationType = $this->relationType === null ? null : $cb($this->relationType); + + if ($this->type === $type && $this->relationType === $relationType) { + return $this; + } + + return new self($type, $this->builderHelper, $relationType, $this->modelQuery); + } + + /** + * Map the inner types together with another builder-of type's. + */ + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (! $right instanceof self + || $this->modelQuery !== $right->modelQuery + || ($this->relationType === null) !== ($right->relationType === null)) { + return $this; + } + + $type = $cb($this->type, $right->type); + $relationType = $this->relationType !== null && $right->relationType !== null + ? $cb($this->relationType, $right->relationType) + : null; + + if ($this->type === $type && $this->relationType === $relationType) { + return $this; + } + + return new self($type, $this->builderHelper, $relationType, $this->modelQuery); + } + + /** + * Convert the type to a PHPDoc node. + */ + public function toPhpDocNode(): TypeNode + { + $types = [$this->type->toPhpDocNode()]; + + if ($this->relationType !== null) { + $types[] = $this->relationType->toPhpDocNode(); + } + + return new GenericTypeNode(new IdentifierTypeNode($this->name()), $types); + } + + /** + * Get the type's PHPDoc name. + */ + private function name(): string + { + return $this->modelQuery ? 'model-query-of' : 'builder-of'; + } + + /** + * Generalize the inner types. + */ + public function generalize(GeneralizePrecision $precision): Type + { + return $this->traverse(static fn (Type $type) => $type->generalize($precision)); + } +} diff --git a/src/phpstan/src/Types/BuilderOf/BuilderOfTypeNodeResolverExtension.php b/src/phpstan/src/Types/BuilderOf/BuilderOfTypeNodeResolverExtension.php new file mode 100644 index 0000000000..809a99d1cc --- /dev/null +++ b/src/phpstan/src/Types/BuilderOf/BuilderOfTypeNodeResolverExtension.php @@ -0,0 +1,72 @@ + or builder-of type node, or its model-query-of form. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof GenericTypeNode || ! in_array((string) $typeNode->type, ['builder-of', 'model-query-of'], true)) { + return null; + } + + if (count($typeNode->genericTypes) < 1 || count($typeNode->genericTypes) > 2) { + return null; + } + + $genericType = $this->typeNodeResolver->resolve($typeNode->genericTypes[0], $nameScope); + + if ($genericType instanceof NeverType || (new ObjectType(Model::class))->isSuperTypeOf($genericType)->no()) { + return null; + } + + $relationType = isset($typeNode->genericTypes[1]) + ? $this->typeNodeResolver->resolve($typeNode->genericTypes[1], $nameScope) + : null; + + if ($relationType !== null && (! $relationType->isString()->yes() || $relationType instanceof NeverType)) { + return null; + } + + return new BuilderOfType($genericType, $this->builderHelper, $relationType, (string) $typeNode->type === 'model-query-of'); + } + + /** + * Set the resolver used for the inner type nodes. + */ + public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void + { + $this->typeNodeResolver = $typeNodeResolver; + } +} diff --git a/src/phpstan/src/Types/CollectionOf/CollectionOfType.php b/src/phpstan/src/Types/CollectionOf/CollectionOfType.php new file mode 100644 index 0000000000..dc0718440b --- /dev/null +++ b/src/phpstan/src/Types/CollectionOf/CollectionOfType.php @@ -0,0 +1,190 @@ + or collection-of. + */ +// @phpstan-ignore phpstanApi.interface (PHPStan's own late-resolvable types are compound, so comparisons resolve them first.) +class CollectionOfType implements CompoundType, LateResolvableType +{ + use LateResolvableTypeTrait; + + /** + * Create a collection-of type. + */ + public function __construct(private Type $type, private CollectionHelper $collectionHelper, private ?Type $keyType = null) + { + } + + /** + * Resolve the collection class of each model the type stands for. + */ + protected function getResult(): Type + { + $keyType = $this->keyType ?? new BenevolentUnionType([new IntegerType, new StringType]); + $results = []; + + foreach (TypeUtils::flattenTypes($this->type) as $modelType) { + foreach ($modelType->getObjectClassNames() as $className) { + if (! (new ObjectType(Model::class))->isSuperTypeOf(new ObjectType($className))->yes()) { + continue; + } + + $results[] = $this->collectionHelper->determineCollectionClass($className, $modelType, $keyType); + } + } + + // Unlike upstream, which resolves to never, an unknown model, such as a relation's `*`, has the base + // model's collection, as builder-of gives an unknown builder. + if ($results === [] && (new ObjectType(Model::class))->isSuperTypeOf($this->type)->maybe()) { + return $this->collectionHelper->determineCollectionClass(Model::class, keyType: $keyType); + } + + return TypeCombinator::union(...$results); + } + + /** + * Determine whether the type can be resolved, which waits for its template types. + */ + public function isResolvable(): bool + { + return ! TypeUtils::containsTemplateType($this->type) + && ($this->keyType === null || ! TypeUtils::containsTemplateType($this->keyType)); + } + + /** + * Get the classes the type refers to. + * + * @return list + */ + public function getReferencedClasses(): array + { + return array_merge($this->type->getReferencedClasses(), $this->keyType?->getReferencedClasses() ?? []); + } + + /** + * Get the template types the type refers to. + * + * @return list + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + // Unlike upstream, the inner types are invariant, as the Eloquent collection's are, so a `*` argument + // resolves as it does there instead of to never in a parameter. + $variance = $positionVariance->compose(TemplateTypeVariance::createInvariant()); + + return array_merge( + $this->type->getReferencedTemplateTypes($variance), + $this->keyType?->getReferencedTemplateTypes($variance) ?? [], + ); + } + + /** + * Determine whether the type equals another. + */ + public function equals(Type $type): bool + { + if (! $type instanceof self || ! $this->type->equals($type->type)) { + return false; + } + + if ($this->keyType === null || $type->keyType === null) { + return $this->keyType === $type->keyType; + } + + return $this->keyType->equals($type->keyType); + } + + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + if ($this->keyType !== null) { + return 'collection-of<' . $this->keyType->describe($level) . ', ' . $this->type->describe($level) . '>'; + } + + return 'collection-of<' . $this->type->describe($level) . '>'; + } + + /** + * Map the inner types. + * + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $type = $cb($this->type); + $keyType = $this->keyType !== null ? $cb($this->keyType) : null; + + if ($this->type === $type && $this->keyType === $keyType) { + return $this; + } + + return new self($type, $this->collectionHelper, $keyType); + } + + /** + * Map the inner types together with another collection-of type's. + */ + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (! $right instanceof self) { + return $this; + } + + $type = $cb($this->type, $right->type); + $keyType = $this->keyType !== null && $right->keyType !== null ? $cb($this->keyType, $right->keyType) : $this->keyType; + + if ($this->type === $type && $this->keyType === $keyType) { + return $this; + } + + return new self($type, $this->collectionHelper, $keyType); + } + + /** + * Convert the type to a PHPDoc node. + */ + public function toPhpDocNode(): TypeNode + { + $genericTypes = $this->keyType !== null + ? [$this->keyType->toPhpDocNode(), $this->type->toPhpDocNode()] + : [$this->type->toPhpDocNode()]; + + return new GenericTypeNode(new IdentifierTypeNode('collection-of'), $genericTypes); + } + + /** + * Generalize the inner types. + */ + public function generalize(GeneralizePrecision $precision): Type + { + return $this->traverse(static fn (Type $type) => $type->generalize($precision)); + } +} diff --git a/src/phpstan/src/Types/CollectionOf/CollectionOfTypeNodeResolverExtension.php b/src/phpstan/src/Types/CollectionOf/CollectionOfTypeNodeResolverExtension.php new file mode 100644 index 0000000000..fcdfa6ad5f --- /dev/null +++ b/src/phpstan/src/Types/CollectionOf/CollectionOfTypeNodeResolverExtension.php @@ -0,0 +1,75 @@ + or collection-of type node. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof GenericTypeNode || (string) $typeNode->type !== 'collection-of') { + return null; + } + + $genericTypes = $typeNode->genericTypes; + + if (count($genericTypes) !== 1 && count($genericTypes) !== 2) { + return null; + } + + // Like array, the optional key type comes first. + $keyType = count($genericTypes) === 2 ? $this->typeNodeResolver->resolve($genericTypes[0], $nameScope) : null; + $genericType = $this->typeNodeResolver->resolve($genericTypes[count($genericTypes) - 1], $nameScope); + + if ($genericType instanceof NeverType || (new ObjectType(Model::class))->isSuperTypeOf($genericType)->no()) { + return null; + } + + if ($keyType !== null && (new UnionType([new IntegerType, new StringType]))->isSuperTypeOf($keyType)->no()) { + return null; + } + + return new CollectionOfType($genericType, $this->collectionHelper, $keyType); + } + + /** + * Set the resolver used for the inner type nodes. + */ + public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void + { + $this->typeNodeResolver = $typeNodeResolver; + } +} diff --git a/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapKind.php b/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapKind.php new file mode 100644 index 0000000000..1c1af0b7f1 --- /dev/null +++ b/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapKind.php @@ -0,0 +1,40 @@ + with + * `@template TRelations of array`, so each constant key's callback expects that key's own relation or builder. + * Upstream's stubs union the keys instead, which accepts a callback written for another key's relation. + * Integer keys, keys that name no model and maps without constant keys keep Laravel's value type. Optional + * keys stay optional, and a supplied callback's type never becomes part of the expected type. + */ +// @phpstan-ignore phpstanApi.interface (PHPStan's own late-resolvable types are compound, so comparisons resolve them first.) +class EagerLoadMapType implements CompoundType, LateResolvableType +{ + use LateResolvableTypeTrait; + + /** + * Create an eager-load map type. + */ + public function __construct( + private EagerLoadMapKind $kind, + private ?Type $model, + private Type $map, + private BuilderHelper $builderHelper, + ) { + } + + /** + * Build the expected map from the map's constant keys. + */ + protected function getResult(): Type + { + if ($this->model === null) { + return $this->morphEntries(); + } + + return $this->kind === EagerLoadMapKind::EagerLoads + ? $this->eagerLoads($this->model, $this->map, '', $this->eagerLoadValue()) + : $this->relationAggregates($this->model, $this->map); + } + + /** + * Build the expected eager loads of a model's relations. + * + * Nested arrays follow Builder::prepareNestedWithRelationships(): their keys continue the path from the + * parent key's name without its column list. Laravel declares nested values as any array, so nested + * entries the keys do not type accept any value. + */ + private function eagerLoads(Type $model, Type $map, string $prefix, Type $fallbackValue): Type + { + return $this->mapEntries($map, $fallbackValue, function (int|string $key, Type $value) use ($model, $prefix): ?Type { + if (! is_string($key)) { + return null; + } + + $relationType = (new RelationOfType($model, new ConstantStringType($prefix . $key)))->resolve(); + + return TypeCombinator::union( + $this->callback($relationType), + new StringType, + $this->eagerLoads($model, $this->arrayPart($value), $prefix . explode(':', $key, 2)[0] . '.', new MixedType), + ); + }); + } + + /** + * Build the expected aggregate constraints of a model's relations. + * + * The relation name is read as QueriesRelationships::withAggregate() reads it, without an alias. Aggregates + * do not follow nested arrays: an array value is only the empty, unconstrained form. A relation that does not + * resolve keeps Laravel's value type and its Builder<*> callback, as an unknown eager load gets Laravel's + * Relation<*, *, *>. + */ + private function relationAggregates(Type $model, Type $map): Type + { + return $this->mapEntries($map, $this->relationAggregateValue(), function (int|string $key) use ($model): ?Type { + if (! is_string($key)) { + return null; + } + + $segments = explode(' ', $key); + $relation = count($segments) === 3 && Str::lower($segments[1]) === 'as' ? $segments[0] : $key; + + [$relationType, $unknown] = (new RelationOfType($model, new ConstantStringType($relation)))->resolveRelations(); + + if ($relationType === null || $unknown) { + return null; + } + + $relatedModel = $relationType->getTemplateType(Relation::class, 'TRelatedModel'); + + return TypeCombinator::union( + ConstantArrayTypeBuilder::createEmpty()->getArray(), + $this->callback((new BuilderOfType($relatedModel, $this->builderHelper))->resolve()), + new StringType, + ); + }); + } + + /** + * Build the expected morph-keyed map, typing each key's value for the models it applies to. + */ + private function morphEntries(): Type + { + $fallbackValue = match ($this->kind) { + EagerLoadMapKind::MorphEagerLoads => TypeCombinator::union(new ArrayType((new MixedType)->toArrayKey(), $this->eagerLoadValue()), new StringType), + EagerLoadMapKind::MorphAggregates => TypeCombinator::union(new ArrayType((new MixedType)->toArrayKey(), $this->relationAggregateValue()), new StringType), + // MorphTo declares its maps as any array. The mixed is explicit, as any value is accepted, so stub + // validation does not read the value type as missing. + default => new MixedType(true), + }; + + return $this->mapEntries($this->map, $fallbackValue, function (int|string $key, Type $value, ConstantArrayType $map): ?Type { + $model = $this->morphModel($key, $map); + + if ($model === null) { + return null; + } + + return match ($this->kind) { + EagerLoadMapKind::MorphTypeConstraints => $this->callback((new BuilderOfType($model, $this->builderHelper))->resolve()), + EagerLoadMapKind::MorphAggregates, EagerLoadMapKind::MorphTypeAggregates => TypeCombinator::union( + $this->relationAggregates($model, $this->arrayPart($value)), + new StringType, + ), + default => TypeCombinator::union( + $this->eagerLoads($model, $this->arrayPart($value), '', $this->eagerLoadValue()), + new StringType, + ), + }; + }); + } + + /** + * Get the models a morph map key applies to, or null when it names none. + * + * MorphTo::resolveMorphTypes() maps a key through the morph map before using it as a class name. loadMorph() + * and its siblings read a model's entry by its class, then by its morph alias, so an alias key applies to its + * model only when the map has no key for that class. + */ + private function morphModel(int|string $key, ConstantArrayType $map): ?Type + { + $morphedModel = Relation::getMorphedModel($key); + + if ($this->kind->resolvesAliasFirst()) { + return $this->modelType($morphedModel ?? (string) $key); + } + + $models = []; + + if (($modelType = $this->modelType((string) $key)) !== null) { + $models[] = $modelType; + } + + if ($morphedModel !== null + && (string) Relation::getMorphAlias($morphedModel) === (string) $key + && ! $map->hasOffsetValueType(new ConstantStringType($morphedModel))->yes() + && ($modelType = $this->modelType($morphedModel)) !== null) { + $models[] = $modelType; + } + + return $models === [] ? null : TypeCombinator::union(...$models); + } + + /** + * Get the type of a model class named exactly, as get_class() names it. + */ + private function modelType(string $className): ?Type + { + $modelType = new ObjectType($className); + + return $modelType->getClassReflection()?->getName() === $className + && (new ObjectType(Model::class))->isSuperTypeOf($modelType)->yes() + ? $modelType + : null; + } + + /** + * Build an expected map from a map's constant keys, keeping the fallback value for other entries. + * + * @param callable(int|string, Type, ConstantArrayType): ?Type $expectedValue + */ + private function mapEntries(Type $map, Type $fallbackValue, callable $expectedValue): Type + { + $results = []; + + // Union members are read whole: flattening a shape would expand its optional keys. + foreach ($map instanceof UnionType ? $map->getTypes() : [$map] as $mapType) { + $constantArrays = $mapType->getConstantArrays(); + + if ($constantArrays === []) { + $results[] = new ArrayType((new MixedType)->toArrayKey(), $fallbackValue); + + continue; + } + + foreach ($constantArrays as $constantArray) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($constantArray->getKeyTypes() as $index => $keyType) { + $builder->setOffsetValueType( + $keyType, + $expectedValue($keyType->getValue(), $constantArray->getValueTypes()[$index], $constantArray) ?? $fallbackValue, + $constantArray->isOptionalKey($index), + ); + } + + if ($constantArray->isUnsealed()->yes()) { + $builder->makeUnsealed($constantArray->getUnsealedTypes()[0], $fallbackValue); + } + + $results[] = $builder->getArray(); + } + } + + return TypeCombinator::union(...$results); + } + + /** + * Get the array part of an entry's value, or any array when it has none. + */ + private function arrayPart(Type $value): Type + { + $arrayType = new ArrayType(new MixedType, new MixedType); + + return $value->isArray()->no() ? $arrayType : TypeCombinator::intersect($value, $arrayType); + } + + /** + * Get a callable type that receives the given type. + */ + private function callback(Type $parameterType): Type + { + return new CallableType( + [new CallableParameterReflection('query', $parameterType)], + new MixedType, + false, + ); + } + + /** + * Get Laravel's eager-load value type: array|(callable(Relation<*, *, *>): mixed)|string. + */ + private function eagerLoadValue(): Type + { + return TypeCombinator::union( + new ArrayType(new MixedType, new MixedType), + $this->callback(new GenericObjectType( + Relation::class, + [new MixedType, new MixedType, new MixedType], + null, + null, + array_fill(0, 3, TemplateTypeVariance::createBivariant()), + )), + new StringType, + ); + } + + /** + * Get Laravel's aggregate value type: array{}|(callable(Builder<*>): mixed)|string. + */ + private function relationAggregateValue(): Type + { + return TypeCombinator::union( + ConstantArrayTypeBuilder::createEmpty()->getArray(), + $this->callback(new GenericObjectType(EloquentBuilder::class, [new MixedType], null, null, [TemplateTypeVariance::createBivariant()])), + new StringType, + ); + } + + /** + * Infer the map template from the whole argument, so each key keeps its own value. + */ + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + return $this->map->inferTemplateTypes($receivedType); + } + + /** + * Determine whether the type can be resolved, which waits for its template types. + */ + public function isResolvable(): bool + { + return ! TypeUtils::containsTemplateType($this->map) + && ($this->model === null || ! TypeUtils::containsTemplateType($this->model)); + } + + /** + * Get the classes the type refers to. + * + * @return list + */ + public function getReferencedClasses(): array + { + return array_merge($this->model?->getReferencedClasses() ?? [], $this->map->getReferencedClasses()); + } + + /** + * Get the template types the type refers to. + * + * @return list + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $variance = $positionVariance->compose(TemplateTypeVariance::createInvariant()); + + return array_merge( + $this->model?->getReferencedTemplateTypes($variance) ?? [], + $this->map->getReferencedTemplateTypes($variance), + ); + } + + /** + * Determine whether the type equals another. + */ + public function equals(Type $type): bool + { + return $type instanceof self + && $this->kind === $type->kind + && $this->map->equals($type->map) + && ($this->model === null ? $type->model === null : $type->model !== null && $this->model->equals($type->model)); + } + + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + return $this->kind->value . '<' + . ($this->model === null ? '' : $this->model->describe($level) . ', ') + . $this->map->describe($level) . '>'; + } + + /** + * Map the inner types. + * + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $model = $this->model === null ? null : $cb($this->model); + $map = $cb($this->map); + + if ($model === $this->model && $map === $this->map) { + return $this; + } + + return new self($this->kind, $model, $map, $this->builderHelper); + } + + /** + * Map the inner types together with another eager-load map type's. + */ + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (! $right instanceof self || $right->kind !== $this->kind) { + return $this; + } + + $model = $this->model !== null && $right->model !== null ? $cb($this->model, $right->model) : $this->model; + $map = $cb($this->map, $right->map); + + if ($model === $this->model && $map === $this->map) { + return $this; + } + + return new self($this->kind, $model, $map, $this->builderHelper); + } + + /** + * Convert the type to a PHPDoc node. + */ + public function toPhpDocNode(): TypeNode + { + $types = $this->model === null ? [] : [$this->model->toPhpDocNode()]; + $types[] = $this->map->toPhpDocNode(); + + return new GenericTypeNode(new IdentifierTypeNode($this->kind->value), $types); + } + + /** + * Generalize the inner types. + */ + public function generalize(GeneralizePrecision $precision): Type + { + return $this->traverse(static fn (Type $type) => $type->generalize($precision)); + } +} diff --git a/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapTypeNodeResolverExtension.php b/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapTypeNodeResolverExtension.php new file mode 100644 index 0000000000..00427d505b --- /dev/null +++ b/src/phpstan/src/Types/EagerLoadMap/EagerLoadMapTypeNodeResolverExtension.php @@ -0,0 +1,87 @@ +, relation-aggregates or morph map type node. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof GenericTypeNode) { + return null; + } + + $kind = EagerLoadMapKind::tryFrom((string) $typeNode->type); + + if ($kind === null || count($typeNode->genericTypes) !== ($kind->hasModel() ? 2 : 1)) { + return null; + } + + $model = null; + + if ($kind->hasModel()) { + $model = $this->typeNodeResolver->resolve($typeNode->genericTypes[0], $nameScope); + + if ($model instanceof NeverType || (new ObjectType(Model::class))->isSuperTypeOf($model)->no()) { + return null; + } + } + + $map = $this->typeNodeResolver->resolve($typeNode->genericTypes[count($typeNode->genericTypes) - 1], $nameScope); + + if ($map instanceof NeverType || $map->isArray()->no()) { + return null; + } + + // A morph map type reads the booted morph map, so what the declaring class declares depends on it. + $className = $nameScope->getClassName(); + + if (! $kind->hasModel() && $className !== null && $this->reflectionProvider->hasClass($className)) { + $this->runtimeInputs->track($this->reflectionProvider->getClass($className)); + } + + return new EagerLoadMapType($kind, $model, $map, $this->builderHelper); + } + + /** + * Set the resolver used for the inner type nodes. + */ + public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void + { + $this->typeNodeResolver = $typeNodeResolver; + } +} diff --git a/src/phpstan/src/Types/Factory/ModelFactoryType.php b/src/phpstan/src/Types/Factory/ModelFactoryType.php new file mode 100644 index 0000000000..af5db73aee --- /dev/null +++ b/src/phpstan/src/Types/Factory/ModelFactoryType.php @@ -0,0 +1,82 @@ +isSingleModel = $isSingleModel ?? TrinaryLogic::createMaybe(); + } + + /** + * Determine whether the factory makes a single model rather than a collection. + */ + public function isSingleModel(): TrinaryLogic + { + return $this->isSingleModel; + } + + /** + * Get the same factory with the given count. + */ + public function withSingleModel(TrinaryLogic $isSingleModel): self + { + return new self($this->getClassName(), null, $this->getClassReflection(), $isSingleModel); + } + + /** + * Determine whether the type is a supertype of the given type. + * + * Unlike upstream, a known count covers another factory only when that factory is known to have the same + * count, so merging branches whose counts differ keeps them apart instead of keeping whichever came first. + */ + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + $result = parent::isSuperTypeOf($type); + + if (! $result->yes() || $this->isSingleModel->maybe() || $type instanceof CompoundType) { + return $result; + } + + return $type instanceof self && $type->isSingleModel->equals($this->isSingleModel) + ? $result + : IsSuperTypeOfResult::createMaybe(); + } + + /** + * Determine whether the type equals the given type, including its count. + */ + public function equals(Type $type): bool + { + return parent::equals($type) && $type instanceof self && $type->isSingleModel->equals($this->isSingleModel); + } + + /** + * Get the count, which keeps PHPStan's cached comparisons apart for factories whose counts differ. + */ + protected function describeAdditionalCacheKey(): string + { + return '-' . $this->isSingleModel->describe(); + } +} diff --git a/src/phpstan/src/Types/FactoryCountResetTypeSpecifyingExtension.php b/src/phpstan/src/Types/FactoryCountResetTypeSpecifyingExtension.php new file mode 100644 index 0000000000..c3287d26b1 --- /dev/null +++ b/src/phpstan/src/Types/FactoryCountResetTypeSpecifyingExtension.php @@ -0,0 +1,87 @@ +null() + && $methodReflection->getDeclaringClass()->getName() === Factory::class + && in_array($methodReflection->getName(), ['createMany', 'createManyQuietly', 'makeMany'], true); + } + + /** + * Mark the receiving factory as making a single model. + */ + public function specifyTypes( + MethodReflection $methodReflection, + MethodCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes { + $factoryType = $scope->getType($node->var); + + // A call such as User::factory() returns a new factory each time, so it must not keep a type. + if ($node->var instanceof CallLike || ! $factoryType instanceof ModelFactoryType) { + return new SpecifiedTypes; + } + + return $this->typeSpecifier->create( + $node->var, + $factoryType->withSingleModel(TrinaryLogic::createYes()), + TypeSpecifierContext::createTrue(), + $scope, + )->setAlwaysOverwriteTypes(); + } + + /** + * Set the type specifier used to narrow the factory. + */ + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } +} diff --git a/src/phpstan/src/Types/GenericEloquentBuilderTypeNodeResolverExtension.php b/src/phpstan/src/Types/GenericEloquentBuilderTypeNodeResolverExtension.php new file mode 100644 index 0000000000..78a42a6b1d --- /dev/null +++ b/src/phpstan/src/Types/GenericEloquentBuilderTypeNodeResolverExtension.php @@ -0,0 +1,109 @@ +`. + */ +#[AutowiredService] +class GenericEloquentBuilderTypeNodeResolverExtension implements TypeNodeResolverExtension +{ + private const int KIND_MODEL = 1; + + private const int KIND_BUILDER = 2; + + /** + * This extension is consulted for every PHPDoc type node, so the verdict + * for a resolved identifier (model class, builder class, or neither) is + * memoized to avoid repeated reflection lookups for common union members + * like `string` or `null`. + * + * @var array + */ + private array $classKind = []; + + /** + * Create the generic Eloquent builder type node resolver. + */ + public function __construct(private ReflectionProvider $provider, private BuilderHelper $builderHelper) + { + } + + /** + * Resolve a two-member union of a model and a builder class. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof UnionTypeNode || count($typeNode->types) !== 2) { + return null; + } + + $modelTypeName = null; + $builderTypeName = null; + + foreach ($typeNode->types as $innerTypeNode) { + // A matching union needs a model member and a builder member, so + // any member that is not a plain identifier means we can bail out. + if (! $innerTypeNode instanceof IdentifierTypeNode) { + return null; + } + + $resolvedName = $nameScope->resolveStringName($innerTypeNode->name); + $kind = $this->resolveClassKind($resolvedName); + + if ($kind === self::KIND_MODEL && $modelTypeName === null) { + $modelTypeName = $resolvedName; + } elseif ($kind === self::KIND_BUILDER && $builderTypeName === null) { + $builderTypeName = $resolvedName; + } + } + + if ($modelTypeName === null || $builderTypeName === null) { + return null; + } + + return $this->builderHelper->getBuilderType(new ObjectType($builderTypeName), new ObjectType($modelTypeName)); + } + + /** + * Determine whether the name is a model class, a builder class, or neither. + */ + private function resolveClassKind(string $resolvedName): ?int + { + if (array_key_exists($resolvedName, $this->classKind)) { + return $this->classKind[$resolvedName]; + } + + $kind = null; + + if ($this->provider->hasClass($resolvedName)) { + $classReflection = $this->provider->getClass($resolvedName); + + if ($classReflection->is(Model::class)) { + $kind = self::KIND_MODEL; + } elseif ($classReflection->is(Builder::class)) { + $kind = self::KIND_BUILDER; + } + } + + return $this->classKind[$resolvedName] = $kind; + } +} diff --git a/src/phpstan/src/Types/GenericEloquentCollectionTypeNodeResolverExtension.php b/src/phpstan/src/Types/GenericEloquentCollectionTypeNodeResolverExtension.php new file mode 100644 index 0000000000..e553265dda --- /dev/null +++ b/src/phpstan/src/Types/GenericEloquentCollectionTypeNodeResolverExtension.php @@ -0,0 +1,94 @@ + $accounts + * + * Now IDEs can benefit from auto-completion, and we can benefit from the correct type passed to the generic collection + */ +#[AutowiredService] +class GenericEloquentCollectionTypeNodeResolverExtension implements TypeNodeResolverExtension +{ + /** + * Create the generic Eloquent collection type node resolver. + */ + public function __construct(private TypeNodeResolver $typeNodeResolver) + { + } + + /** + * Resolve a two-member union of the Eloquent collection and an array of models. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof UnionTypeNode || count($typeNode->types) !== 2) { + return null; + } + + $arrayTypeNode = null; + $identifierTypeNode = null; + + foreach ($typeNode->types as $innerTypeNode) { + if ($innerTypeNode instanceof ArrayTypeNode) { + $arrayTypeNode = $innerTypeNode; + continue; + } + + if (! $innerTypeNode instanceof IdentifierTypeNode) { + continue; + } + + $identifierTypeNode = $innerTypeNode; + } + + if ($arrayTypeNode === null || $identifierTypeNode === null) { + return null; + } + + $identifierTypeName = $nameScope->resolveStringName($identifierTypeNode->name); + + if ($identifierTypeName !== Collection::class) { + return null; + } + + $innerArrayTypeNode = $arrayTypeNode->type; + + if (! $innerArrayTypeNode instanceof IdentifierTypeNode) { + return null; + } + + $resolvedInnerArrayType = $this->typeNodeResolver->resolve($innerArrayTypeNode, $nameScope); + + return new GenericObjectType($identifierTypeName, [ + new IntegerType, + $resolvedInnerArrayType, + ]); + } +} diff --git a/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php b/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php new file mode 100644 index 0000000000..82ff57111f --- /dev/null +++ b/src/phpstan/src/Types/ModelProperty/GenericModelPropertyType.php @@ -0,0 +1,254 @@ +. + */ +class GenericModelPropertyType extends StringType +{ + /** + * Create a new generic model property type instance. + */ + public function __construct(private Type $type, private ModelPropertyHelper $modelPropertyHelper) + { + parent::__construct(); + } + + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + return 'model property of ' . $this->type->describe($level); + } + + /** + * Get the classes the type refers to. + * + * @return string[] + */ + public function getReferencedClasses(): array + { + return $this->getGenericType()->getReferencedClasses(); + } + + /** + * Get the model type whose properties the string names. + */ + public function getGenericType(): Type + { + return $this->type; + } + + /** + * Determine whether the type equals another. + */ + public function equals(Type $type): bool + { + return $type instanceof self && $this->type->equals($type->type); + } + + /** + * Determine whether the type accepts another, checking a constant string against the model's properties. + * + * A "table.column" string is checked against that table on the model's connection. + */ + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + if (count($type->getConstantStrings()) === 1) { + $givenString = $type->getConstantStrings()[0]->getValue(); + $genericType = $this->getGenericType(); + + if ($genericType instanceof MixedType) { + return AcceptsResult::createYes(); + } + + if (count($genericType->getObjectClassNames()) < 1) { + return AcceptsResult::createYes(); + } + + if (str_contains($givenString, ' as ')) { + $givenString = explode(' as ', $givenString)[0]; + } + + if (str_contains($givenString, '.')) { + $parts = explode('.', $givenString); + + if (count($parts) !== 2) { + return AcceptsResult::createYes(); + } + + [$tableName, $propertyName] = $parts; + + // Unlike upstream, "table.*" selects every column, as "*" does. + if ($propertyName === '*') { + return AcceptsResult::createYes(); + } + + foreach ($genericType->getObjectClassReflections() as $classReflection) { + if ($this->modelPropertyHelper->hasTableColumn($classReflection, $tableName, $propertyName)) { + return AcceptsResult::createYes(); + } + } + + return AcceptsResult::createNo([sprintf('Database table "%s" does not have column "%s"', $tableName, $propertyName)]); + } + + $accepted = $this->modelPropertyHelper->acceptsPropertyName($genericType, $givenString); + $reasons = []; + + if (! $accepted->yes()) { + $reasons[] = sprintf('The given string should be a property of %s, %s given.', $this->type->describe(VerbosityLevel::value()), $givenString); + } + + return new AcceptsResult($accepted, $reasons); + } + + if ($type instanceof self) { + return new AcceptsResult($this->getGenericType()->accepts($type->getGenericType(), $strictTypes)->result, [sprintf('The given string should be a property of %s', $this->type->describe(VerbosityLevel::value()))]); + } + + if ($type->isString()->yes()) { + // It is an arbitrary string, so we cannot check if it is a property of the model. + // We return yes to not cause issues on levels 7+ + return AcceptsResult::createYes(); + } + + return AcceptsResult::createNo(); + } + + /** + * Determine whether the type is a supertype of another. + */ + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + $constantStrings = $type->getConstantStrings(); + + if (count($constantStrings) === 1) { + // Unlike upstream, an unknown property stays maybe. Unlike accepts(), an abstract model is + // not yes either: union simplification would drop alternatives such as '*' from stub types. + return new IsSuperTypeOfResult($this->getGenericType()->hasInstanceProperty($constantStrings[0]->getValue()), []); + } + + if ($type instanceof self) { + return $this->getGenericType()->isSuperTypeOf($type->getGenericType()); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + // Unlike upstream, an arbitrary string may name a property, as accepts() allows. PHPStan rechecks an + // accepted intersection, such as a non-empty array with string keys, through isSuperTypeOf(). + return $type->isString()->yes() ? IsSuperTypeOfResult::createMaybe() : IsSuperTypeOfResult::createNo(); + } + + /** + * Map the model type through a callback. + */ + public function traverse(callable $cb): Type + { + $newType = $cb($this->getGenericType()); + + if ($newType === $this->getGenericType()) { + return $this; + } + + return new self($newType, $this->modelPropertyHelper); + } + + /** + * Infer the model template type from a received type. + */ + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + // @phpstan-ignore phpstanApi.instanceofType (Each member infers on its own, as in PHPStan's generic types.) + if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { + return $receivedType->inferTemplateTypesOn($this); + } + + $constantStrings = $receivedType->getConstantStrings(); + + if (count($constantStrings) === 1) { + $typeToInfer = new ObjectType($constantStrings[0]->getValue()); + } elseif ($receivedType instanceof self) { + $typeToInfer = $receivedType->type; + } elseif ($receivedType->isClassString()->yes()) { + $typeToInfer = $this->getGenericType(); + + if ($typeToInfer instanceof TemplateType) { + $typeToInfer = $typeToInfer->getBound(); + } + + $typeToInfer = TypeCombinator::intersect($typeToInfer, new ObjectWithoutClassType); + } else { + return TemplateTypeMap::createEmpty(); + } + + if (! $this->getGenericType()->isSuperTypeOf($typeToInfer)->no()) { + return $this->getGenericType()->inferTemplateTypes($typeToInfer); + } + + return TemplateTypeMap::createEmpty(); + } + + /** + * Get the template types the model type refers to. + * + * @return TemplateTypeReference[] + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $variance = $positionVariance->compose(TemplateTypeVariance::createCovariant()); + + return $this->getGenericType()->getReferencedTemplateTypes($variance); + } + + /** + * Determine whether the type has template or late-resolvable parts. + */ + public function hasTemplateOrLateResolvableType(): bool + { + return true; + } + + /** + * Restore the type from var_export() output. + * + * @param mixed[] $properties + */ + public static function __set_state(array $properties): Type + { + return new self($properties['type'], $properties['modelPropertyHelper']); + } +} diff --git a/src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php b/src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php new file mode 100644 index 0000000000..6c69ded835 --- /dev/null +++ b/src/phpstan/src/Types/ModelProperty/ModelPropertyTypeNodeResolverExtension.php @@ -0,0 +1,83 @@ + type node. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof GenericTypeNode) { + return null; + } + + if ((string) $typeNode->type !== 'model-property') { + return null; + } + + if (! $this->active) { + return new StringType; + } + + if (count($typeNode->genericTypes) !== 1) { + return new ErrorType; + } + + $genericType = $this->baseResolver->resolve($typeNode->genericTypes[0], $nameScope); + + if ((new ObjectType(Model::class))->isSuperTypeOf($genericType)->no()) { + return new ErrorType; + } + + if ($genericType instanceof NeverType) { + return new ErrorType; + } + + // A file calling a method with such a parameter does not have to use a model itself + $className = $nameScope->getClassName(); + + if ($className !== null && $this->reflectionProvider->hasClass($className)) { + $this->modelPropertyHelper->trackSchemaDependency($this->reflectionProvider->getClass($className)); + } + + return new GenericModelPropertyType($genericType, $this->modelPropertyHelper); + } +} diff --git a/src/phpstan/src/Types/RelationOf/RelationOfType.php b/src/phpstan/src/Types/RelationOf/RelationOfType.php new file mode 100644 index 0000000000..46e83357db --- /dev/null +++ b/src/phpstan/src/Types/RelationOf/RelationOfType.php @@ -0,0 +1,244 @@ +. + */ +// @phpstan-ignore phpstanApi.interface (PHPStan's own late-resolvable types are compound, so comparisons resolve them first.) +class RelationOfType implements CompoundType, LateResolvableType +{ + use LateResolvableTypeTrait; + + /** + * Create a relation-of type. + */ + public function __construct(private Type $model, private Type $key) + { + } + + /** + * Resolve the relationship type, or a relation of unknown models. + */ + protected function getResult(): Type + { + [$relationType, $unknown] = $this->resolveRelations(); + + $relations = $relationType === null ? [] : [$relationType]; + + if ($unknown) { + $relations[] = new GenericObjectType( + Relation::class, + [new MixedType, new MixedType, new MixedType], + null, + null, + array_fill(0, 3, TemplateTypeVariance::createBivariant()), + ); + } + + // Unlike upstream's benevolent union, a callback for several possible relations must accept each of them. + $relation = TypeCombinator::union(...$relations); + + // Eager loading creates the relation on a fresh model instance. + return TypeTraverser::map($relation, static fn (Type $type, callable $traverse): Type => $type instanceof StaticType ? $type->getStaticObjectType() : $traverse($type)); + } + + /** + * Resolve the relationships the paths name. + * + * Unlike upstream, a path that does not resolve is unknown rather than missing, kept beside the + * paths that do: the name can be a non-constant string, belong to an abstract model, or name a + * relation that resolveRelationUsing() registers at boot. + * + * @internal + * + * @return array{null|Type, bool} the relationship type of the paths that resolve, and whether any path does not + */ + public function resolveRelations(): array + { + $relations = $this->key->getConstantStrings(); + + if ($relations === []) { + return [null, true]; + } + + $results = []; + $unknown = false; + + foreach ($relations as $relation) { + [$relationType, $pathUnknown] = $this->followRelationPath($relation->getValue()); + $unknown = $unknown || $pathUnknown; + + if ($relationType !== null) { + $results[] = $relationType; + } + } + + return [$results === [] ? null : TypeCombinator::union(...$results), $unknown]; + } + + /** + * Follow a dotted relation path, ignoring a column list after a colon. + * + * Unlike upstream, a model alternative without the relation is unknown, as a path that does not resolve is. + * + * @return array{null|Type, bool} the relationship type of the alternatives the path resolves for, and whether + * any does not resolve + */ + private function followRelationPath(string $path): array + { + $relatedType = $this->model; + $relationType = null; + $unknown = false; + + foreach (explode('.', explode(':', $path, 2)[0]) as $relationName) { + $relations = []; + + foreach (TypeUtils::flattenTypes($relatedType) as $modelType) { + $returnType = $modelType->hasMethod($relationName)->yes() + ? ParametersAcceptorSelector::selectFromTypes([], $modelType->getMethod($relationName, new OutOfClassScope)->getVariants(), false)->getReturnType() + : null; + + if ($returnType === null || ! (new ObjectType(Relation::class))->isSuperTypeOf($returnType)->yes()) { + $unknown = true; + + continue; + } + + $relations[] = $returnType; + } + + if ($relations === []) { + return [null, true]; + } + + $relationType = TypeCombinator::union(...$relations); + $relatedType = $relationType->getTemplateType(Relation::class, 'TRelatedModel'); + } + + return [$relationType, $unknown]; + } + + /** + * Determine whether the type can be resolved, which waits for its template types. + */ + public function isResolvable(): bool + { + return ! TypeUtils::containsTemplateType($this->model) && ! TypeUtils::containsTemplateType($this->key); + } + + /** + * Get the classes the type refers to. + * + * @return list + */ + public function getReferencedClasses(): array + { + return array_merge($this->model->getReferencedClasses(), $this->key->getReferencedClasses()); + } + + /** + * Get the template types the type refers to. + * + * @return list + */ + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + // Unlike upstream, the inner types are invariant, as a relation's declaring model is, so a `*` argument + // resolves as it does there instead of to never in a parameter. + $variance = $positionVariance->compose(TemplateTypeVariance::createInvariant()); + + return array_merge( + $this->model->getReferencedTemplateTypes($variance), + $this->key->getReferencedTemplateTypes($variance), + ); + } + + /** + * Determine whether the type equals another. + */ + public function equals(Type $type): bool + { + return $type instanceof self && $this->model->equals($type->model) && $this->key->equals($type->key); + } + + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + return 'relation-of<' . $this->model->describe($level) . ', ' . $this->key->describe($level) . '>'; + } + + /** + * Map the inner types. + * + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $model = $cb($this->model); + $key = $cb($this->key); + + return $model === $this->model && $key === $this->key ? $this : new self($model, $key); + } + + /** + * Map the inner types together with another relation-of type's. + */ + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (! $right instanceof self) { + return $this; + } + + $model = $cb($this->model, $right->model); + $key = $cb($this->key, $right->key); + + return $model === $this->model && $key === $this->key ? $this : new self($model, $key); + } + + /** + * Convert the type to a PHPDoc node. + */ + public function toPhpDocNode(): TypeNode + { + return new GenericTypeNode(new IdentifierTypeNode('relation-of'), [$this->model->toPhpDocNode(), $this->key->toPhpDocNode()]); + } + + /** + * Generalize the inner types. + */ + public function generalize(GeneralizePrecision $precision): Type + { + return $this->traverse(static fn (Type $type) => $type->generalize($precision)); + } +} diff --git a/src/phpstan/src/Types/RelationOf/RelationOfTypeNodeResolverExtension.php b/src/phpstan/src/Types/RelationOf/RelationOfTypeNodeResolverExtension.php new file mode 100644 index 0000000000..2fbb786d06 --- /dev/null +++ b/src/phpstan/src/Types/RelationOf/RelationOfTypeNodeResolverExtension.php @@ -0,0 +1,56 @@ + type node. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof GenericTypeNode || (string) $typeNode->type !== 'relation-of' || count($typeNode->genericTypes) !== 2) { + return null; + } + + $model = $this->typeNodeResolver->resolve($typeNode->genericTypes[0], $nameScope); + $key = $this->typeNodeResolver->resolve($typeNode->genericTypes[1], $nameScope); + + if ($model instanceof NeverType || (new ObjectType(Model::class))->isSuperTypeOf($model)->no()) { + return null; + } + + if ($key instanceof NeverType || ! $key->isString()->yes()) { + return null; + } + + return new RelationOfType($model, $key); + } + + /** + * Set the resolver used for the inner type nodes. + */ + public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void + { + $this->typeNodeResolver = $typeNodeResolver; + } +} diff --git a/src/phpstan/src/Types/ViewStringType.php b/src/phpstan/src/Types/ViewStringType.php new file mode 100644 index 0000000000..279a458383 --- /dev/null +++ b/src/phpstan/src/Types/ViewStringType.php @@ -0,0 +1,118 @@ +exists($string) test is a valid view-string type. + */ +class ViewStringType extends StringType +{ + /** + * Describe the type. + */ + public function describe(VerbosityLevel $level): string + { + return 'view-string'; + } + + /** + * Determine whether the type accepts the given type. + */ + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + $constantStrings = $type->getConstantStrings(); + + if (count($constantStrings) === 1) { + /** @var Factory $view */ + $view = view(); + + return AcceptsResult::createFromBoolean($view->exists($constantStrings[0]->getValue())); + } + + if ($type instanceof self) { + return AcceptsResult::createYes(); + } + + if ($type->isString()->yes()) { + return AcceptsResult::createMaybe(); + } + + return AcceptsResult::createNo(); + } + + /** + * Determine whether the type is a supertype of the given type. + */ + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + $constantStrings = $type->getConstantStrings(); + + if (count($constantStrings) === 1) { + /** @var Factory $view */ + $view = view(); + + return IsSuperTypeOfResult::createFromBoolean($view->exists($constantStrings[0]->getValue())); + } + + if ($type instanceof self) { + return IsSuperTypeOfResult::createYes(); + } + + if ($type->isString()->yes()) { + return IsSuperTypeOfResult::createMaybe(); + } + + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + return IsSuperTypeOfResult::createNo(); + } + + /** + * Determine whether a view-string type occurs in the type, such as in a union or a callable signature. + */ + public static function occursIn(Type $type): bool + { + $found = false; + + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$found): Type { + if ($type instanceof self) { + $found = true; + } + + return $found ? $type : $traverse($type); + }); + + return $found; + } + + /** + * Restore the type from its exported state. + * + * @param mixed[] $properties + */ + public static function __set_state(array $properties): Type + { + return new self; + } +} diff --git a/src/phpstan/src/Types/ViewStringTypeNodeResolverExtension.php b/src/phpstan/src/Types/ViewStringTypeNodeResolverExtension.php new file mode 100644 index 0000000000..e80a11a8ea --- /dev/null +++ b/src/phpstan/src/Types/ViewStringTypeNodeResolverExtension.php @@ -0,0 +1,106 @@ + + */ + private array $resolvingAliases = []; + + /** + * Create a new view-string type node resolver. + * + * @param array $typeAliases the global type aliases + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + #[AutowiredParameter] + private array $typeAliases, + ) { + } + + /** + * Resolve a view-string PHPDoc type, or a type alias that holds one. + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (! $typeNode instanceof IdentifierTypeNode) { + return null; + } + + if ($typeNode->name === 'view-string') { + $this->trackViews($nameScope); + + return new ViewStringType; + } + + if ($nameScope->shouldBypassTypeAliases() + || isset($this->resolvingAliases[$typeNode->name]) + || (! $nameScope->hasTypeAlias($typeNode->name) && ! isset($this->typeAliases[$typeNode->name]))) { + return null; + } + + $this->resolvingAliases[$typeNode->name] = true; + + try { + $type = $this->typeNodeResolver->resolve($typeNode, $nameScope); + } finally { + unset($this->resolvingAliases[$typeNode->name]); + } + + if (ViewStringType::occursIn($type)) { + $this->trackViews($nameScope); + } + + return $type; + } + + /** + * Set the resolver used to resolve type aliases. + */ + public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void + { + $this->typeNodeResolver = $typeNodeResolver; + } + + /** + * Declare that what the class whose PHPDoc is resolved declares depends on which views exist. + */ + private function trackViews(NameScope $nameScope): void + { + $className = $nameScope->getClassName(); + + if ($className !== null && $this->reflectionProvider->hasClass($className)) { + $this->runtimeInputs->trackViews($this->reflectionProvider->getClass($className)); + } + } +} diff --git a/src/phpstan/stubs/common/BelongsToMany.stub b/src/phpstan/stubs/common/BelongsToMany.stub new file mode 100644 index 0000000000..27cba3289e --- /dev/null +++ b/src/phpstan/stubs/common/BelongsToMany.stub @@ -0,0 +1,273 @@ +> + * + * @todo use TAccessor when PHPStan bug is fixed: https://github.com/phpstan/phpstan/issues/12756 + */ +class BelongsToMany extends Relation +{ + /** + * Prepare the query builder for query execution. + * + * @return builder-of + */ + protected function prepareQueryBuilder(): Builder; + + /** + * Find a related model by its primary key or return a new instance of the related model. + * + * @param array|Expression|'*'> $columns + * @return ( + * $id is Model ? TRelatedModel : ( + * $id is (Arrayable>|array) + * ? collection-of + * : TRelatedModel + * ) + * ) + */ + public function findOrNew(mixed $id, array $columns = ['*']); + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @param array $joining + * @return TRelatedModel + */ + public function firstOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @param array $joining + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrFirst(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model; + + /** + * Get the first record matching the attributes, including trashed records, and restore it. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @param array $joining + * @return TRelatedModel + */ + public function restoreOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, find the matching record, including trashed records, and restore it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @param array $joining + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrRestore(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model; + + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @param array $joining + * @return TRelatedModel + */ + public function updateOrCreate(array $attributes, Closure|array $values = [], array $joining = [], bool $touch = true): Model; + + /** + * Create a related record matching the attributes, or increment the existing record. + * + * @param array, mixed> $attributes + * @param model-property $column + * @param array, mixed> $extra + * @return TRelatedModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model; + + /** + * Find a related model by its primary key. + * + * @param array|Expression|'*'> $columns + * @return ( + * $id is Model ? (TRelatedModel&object{pivot: TPivotModel})|null : ( + * $id is (Arrayable>|array) + * ? collection-of + * : (TRelatedModel&object{pivot: TPivotModel})|null + * ) + * ) + */ + public function find(mixed $id, array $columns = ['*']); + + /** + * Find multiple related models by their primary keys. + * + * @param array|Arrayable> $ids + * @param array|Expression|'*'> $columns + * @return collection-of + */ + public function findMany(Arrayable|array $ids, array $columns = ['*']); + + /** + * Find a related model by its primary key or throw an exception. + * + * @param array|Expression|'*'> $columns + * @return ( + * $id is Model ? TRelatedModel&object{pivot: TPivotModel} : ( + * $id is (Arrayable>|array) + * ? collection-of + * : TRelatedModel&object{pivot: TPivotModel} + * ) + * ) + * + * @throws ModelNotFoundException + */ + public function findOrFail(mixed $id, array $columns = ['*']); + + /** + * Find a related model by its primary key or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|Expression|'*'>|model-property|'*' $columns + * @param null|(Closure(): TValue) $callback + * @return ( + * $id is Model ? (TRelatedModel&object{pivot: TPivotModel})|TValue : ( + * $id is (Arrayable>|array) + * ? collection-of|TValue + * : (TRelatedModel&object{pivot: TPivotModel})|TValue + * ) + * ) + */ + public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Add a basic where clause to the query, and return the first result. + * + * @param array, mixed>|(Closure(($operator is null ? model-query-of : QueryBuilder)): mixed)|model-property $column + * @return null|(object{pivot: TPivotModel}&TRelatedModel) + */ + public function firstWhere(Closure|string|array $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model; + + /** + * Execute the query and get the first result. + * + * @param array|Expression|'*'> $columns + * @return null|(object{pivot: TPivotModel}&TRelatedModel) + */ + public function first(array $columns = ['*']): ?Model; + + /** + * Execute the query and get the first result or throw an exception. + * + * @param array|Expression|'*'> $columns + * @return object{pivot: TPivotModel}&TRelatedModel + * + * @throws ModelNotFoundException + */ + public function firstOrFail(array $columns = ['*']): Model; + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|Expression|'*'> $columns + * @param null|(Closure(): TValue) $callback + * @return (object{pivot: TPivotModel}&TRelatedModel)|TValue + */ + public function firstOr(Closure|array $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Execute the query as a "select" statement. + * + * @param array|Expression|'*'> $columns + * @return collection-of + */ + public function get(array $columns = ['*']); + + /** + * Chunk the results of the query. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunk(int $count, callable $callback): bool; + + /** + * Chunk the results of a query by comparing numeric IDs. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; + + /** + * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; + + /** + * Chunk the results of a query by comparing IDs in a given order. + * + * @param callable(collection-of, int): mixed $callback + */ + public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): bool; + + /** + * Create a new instance of the related model. + * + * @param array, mixed> $attributes + * @param array $joining + * @return TRelatedModel + */ + public function create(array $attributes = [], array $joining = [], bool $touch = true): Model; +} diff --git a/src/phpstan/stubs/common/Cache/CacheManager.stub b/src/phpstan/stubs/common/Cache/CacheManager.stub new file mode 100644 index 0000000000..e67c7d7b2a --- /dev/null +++ b/src/phpstan/stubs/common/Cache/CacheManager.stub @@ -0,0 +1,6 @@ + + */ +interface Arrayable +{} + +interface Jsonable +{} + +interface CanBeEscapedWhenCastToString +{} + diff --git a/src/phpstan/stubs/common/Contracts/View.stub b/src/phpstan/stubs/common/Contracts/View.stub new file mode 100644 index 0000000000..7e4e6d06ce --- /dev/null +++ b/src/phpstan/stubs/common/Contracts/View.stub @@ -0,0 +1,27 @@ + $data + * @param array $mergeData + */ + public function make(string $view, Arrayable|array $data = [], array $mergeData = []): View; +} + +interface View +{} diff --git a/src/phpstan/stubs/common/Cookie/CookieJar.stub b/src/phpstan/stubs/common/Cookie/CookieJar.stub new file mode 100644 index 0000000000..26a7599f5e --- /dev/null +++ b/src/phpstan/stubs/common/Cookie/CookieJar.stub @@ -0,0 +1,6 @@ + + */ +class BelongsTo extends Relation +{} + +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * + * @extends BelongsTo + */ +class MorphTo extends BelongsTo +{ + /** + * Specify which relations to load for a given morph type. + * + * @template TWith of array + * @param morph-type-eager-loads $with + * @return $this + */ + public function morphWith(array $with): static; + + /** + * Specify which relationship counts to load for a given morph type. + * + * @template TWithCount of array + * @param morph-type-aggregates $withCount + * @return $this + */ + public function morphWithCount(array $withCount): static; + + /** + * Specify constraints on the query for a given morph type. + * + * @template TCallbacks of array + * @param morph-type-constraints $callbacks + * @return $this + */ + public function constrain(array $callbacks): static; +} diff --git a/src/phpstan/stubs/common/Database/Query.stub b/src/phpstan/stubs/common/Database/Query.stub new file mode 100644 index 0000000000..1142201023 --- /dev/null +++ b/src/phpstan/stubs/common/Database/Query.stub @@ -0,0 +1,14 @@ + $abilities, mixed $user = null) + * @method $this fetchUsing(mixed ...$fetchUsing) + * @method $this useWritePdo() + * + * @mixin QueryBuilder + */ +class Builder +{ + /** + * Add a relationship count / exists condition to the query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? model-query-of : model-query-of)): mixed) $callback + * @return $this + * + * @throws RuntimeException + */ + public function has(Relation|string $relation, string $operator = '>=', Expression|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static; + + /** + * Add a relationship count / exists condition to the query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? model-query-of : model-query-of)): mixed) $callback + * @return $this + */ + public function doesntHave(Relation|string $relation, string $boolean = 'and', ?Closure $callback = null): static; + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? model-query-of : model-query-of)): mixed) $callback + * @return $this + */ + public function whereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; + + /** + * Add a relationship count / exists condition to the query with where clauses and an "or". + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? model-query-of : model-query-of)): mixed) $callback + * @return $this + */ + public function orWhereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? model-query-of : model-query-of)): mixed) $callback + * @return $this + */ + public function whereDoesntHave(Relation|string $relation, ?Closure $callback = null): static; + + /** + * Add a relationship count / exists condition to the query with where clauses and an "or". + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param null|(Closure(($relation is string ? model-query-of : model-query-of)): mixed) $callback + * @return $this + */ + public function orWhereDoesntHave(Relation|string $relation, ?Closure $callback = null): static; + + /** + * Add a polymorphic relationship count / exists condition to the query. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(model-query-of, string): mixed) $callback + * @return $this + */ + public function hasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', Expression|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static; + + /** + * Add a polymorphic relationship count / exists condition to the query. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(model-query-of, string): mixed) $callback + * @return $this + */ + public function doesntHaveMorph(MorphTo|string $relation, string|array $types, string $boolean = 'and', ?Closure $callback = null): static; + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(model-query-of, string): mixed) $callback + * @return $this + */ + public function whereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(model-query-of, string): mixed) $callback + * @return $this + */ + public function orWhereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(model-query-of, string): mixed) $callback + * @return $this + */ + public function whereDoesntHaveMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null): static; + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param null|(Closure(model-query-of, string): mixed) $callback + * @return $this + */ + public function orWhereDoesntHaveMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null): static; + + /** + * Add a basic where clause to a relationship query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param array|(Closure(($relation is string ? model-query-of : model-query-of)): mixed)|Expression|string $column + * @return $this + */ + public function whereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add an "or where" clause to a relationship query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param array|(Closure(($relation is string ? model-query-of : model-query-of)): mixed)|Expression|string $column + * @return $this + */ + public function orWhereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a basic count / exists condition to a relationship query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param array|(Closure(($relation is string ? model-query-of : model-query-of)): mixed)|Expression|string $column + * @return $this + */ + public function whereDoesntHaveRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add an "or where" clause to a relationship query. + * + * @template TRelation of string + * @template TRelatedModel of Model + * + * @param TRelation|Relation $relation + * @param array|(Closure(($relation is string ? model-query-of : model-query-of)): mixed)|Expression|string $column + * @return $this + */ + public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a polymorphic relationship condition to the query with a where clause. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param array|(Closure(model-query-of): mixed)|Expression|string $column + * @return $this + */ + public function whereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a polymorphic relationship condition to the query with an "or where" clause. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param array|(Closure(model-query-of): mixed)|Expression|string $column + * @return $this + */ + public function orWhereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a polymorphic relationship condition to the query with a doesn't have clause. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param array|(Closure(model-query-of): mixed)|Expression|string $column + * @return $this + */ + public function whereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a polymorphic relationship condition to the query with an "or doesn't have" clause. + * + * @template TMorphModel of Model + * @template TType of string + * + * @param MorphTo<*, *>|string $relation + * @param array|TType>|class-string|TType $types + * @param array|(Closure(model-query-of): mixed)|Expression|string $column + * @return $this + */ + public function orWhereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * Also load the relationship with the same condition. + * + * @template TRelation of string + * + * @param TRelation $relation + * @param null|(Closure(($relation is literal-string ? model-query-of : Builder<*>)|relation-of): mixed) $callback + * @return $this + */ + public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static; + + /** + * Add a basic where clause to a relationship query and eager-load the relationship with the same conditions. + * + * @template TRelation of string + * + * @param TRelation $relation + * @param array|(Closure(($relation is literal-string ? model-query-of : Builder<*>)|relation-of): mixed)|Expression|string $column + * @return $this + */ + public function withWhereRelation(string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static; + + /** + * Specify relationships that should be eager loaded. + * + * @template TRelation of string + * @template TRelations of array + * + * @param TRelation|eager-loads $relations + * @param null|(Closure(relation-of): mixed)|string $callback + * @return $this + */ + public function with(array|string $relations, Closure|string|null $callback = null): static; + + /** + * Set the relationships that should be eager loaded while removing any previously added eager loading specifications. + * + * @template TRelations of array + * + * @param eager-loads|string $relations + * @return $this + */ + public function withOnly(array|string $relations): static; + + /** + * Create and return an un-saved model instance. + * + * @param array, mixed> $attributes + * @return TModel + */ + public function make(array $attributes = []): Model; + + /** + * Save a new model and return the instance. + * + * @param array, mixed> $attributes + * @return TModel + */ + public function create(array $attributes = []): Model; + + /** + * Save a new model and return the instance without raising model events. + * + * @param array, mixed> $attributes + * @return TModel + */ + public function createQuietly(array $attributes = []): Model; + + /** + * Create a collection of models from plain arrays. + * + * @param array $items + * @return collection-of + */ + public function hydrate(array $items); + + /** + * Create a collection of models from a raw query. + * + * @param array $bindings + * @return collection-of + */ + public function fromQuery(string $query, array $bindings = []); + + /** + * Find a model by its primary key. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return ($id is Model ? null|TModel : ($id is (array|Arrayable>) ? collection-of : null|TModel)) + */ + public function find(mixed $id, array|string $columns = ['*']); + + /** + * Find a sole model by its primary key. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return TModel + * + * @throws ModelNotFoundException + * @throws MultipleRecordsFoundException + */ + public function findSole(mixed $id, array|string $columns = ['*']): Model; + + /** + * Find multiple models by their primary keys. + * + * @param Arrayable|array $ids + * @param array|Expression|'*'>|model-property|'*' $columns + * @return collection-of + */ + public function findMany(Arrayable|array $ids, array|string $columns = ['*']); + + /** + * Find a model by its primary key or throw an exception. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return ($id is Model ? TModel : ($id is (array|Arrayable>) ? collection-of : TModel)) + * + * @throws ModelNotFoundException + */ + public function findOrFail(mixed $id, array|string $columns = ['*']); + + /** + * Find a model by its primary key or return fresh model instance. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return ($id is Model ? TModel : ($id is (array|Arrayable>) ? collection-of : TModel)) + */ + public function findOrNew(mixed $id, array|string $columns = ['*']); + + /** + * Find a model by its primary key or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|array|Expression|'*'>|model-property|'*' $columns + * @param null|(Closure(): TValue) $callback + * @return ( + * $id is Model ? TModel|TValue : ( + * $id is (Arrayable>|array) + * ? collection-of|TValue + * : TModel|TValue + * ) + * ) + */ + public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Execute the query and get the first result. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return null|TModel + */ + public function first(array|string $columns = ['*']); + + /** + * Get the first record matching the attributes or instantiate it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TModel + */ + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TModel + */ + public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model; + + /** + * Execute the query and get the first result or throw an exception. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return TModel + * + * @throws ModelNotFoundException + */ + public function firstOrFail(array|string $columns = ['*']): Model; + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|Expression|'*'> $columns + * @param null|(Closure(): TValue) $callback + * @return TModel|TValue + */ + public function firstOr(Closure|array $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrFirst(array $attributes = [], Closure|array $values = []): Model; + + /** + * Create or update a record matching the attributes, and fill it with values. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TModel + */ + public function updateOrCreate(array $attributes, Closure|array $values = []): Model; + + /** + * Create a record matching the attributes, or increment the existing record. + * + * @param array, mixed> $attributes + * @param model-property $column + * @param array, mixed> $extra + * @return TModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model; + + /** + * Save a new model and return the instance. Allow mass-assignment. + * + * @param array, mixed> $attributes + * @return TModel + */ + public function forceCreate(array $attributes): Model; + + /** + * Save a new model instance with mass assignment without raising model events. + * + * @param array, mixed> $attributes + * @return TModel + */ + public function forceCreateQuietly(array $attributes = []): Model; + + /** + * Update records in the database. + * + * @param array, mixed> $values + */ + public function update(array $values): int; + + /** + * Insert new records or update the existing ones. + * + * @param array, scalar|object|resource|null>|array, mixed>> $values + * @param model-property|array> $uniqueBy + * @param null|array, mixed> $update + */ + public function upsert(array $values, array|string $uniqueBy, ?array $update = null): int; + + /** + * Insert into the database after merging the model's default attributes, setting timestamps, and casting values. + * + * @param array, mixed>> $values + */ + public function fillAndInsert(array $values): bool; + + /** + * Insert (ignoring errors) into the database after merging the model's default attributes, setting timestamps, and casting values. + * + * @param array, mixed>> $values + */ + public function fillAndInsertOrIgnore(array $values): int; + + /** + * Insert a record into the database and get its ID after merging the model's default attributes, setting timestamps, and casting values. + * + * @param array, mixed> $values + */ + public function fillAndInsertGetId(array $values): int; + + /** + * Enrich the given values by merging in the model's default attributes, adding timestamps, and casting values. + * + * @param array, mixed>> $values + * @return array> + */ + public function fillForInsert(array $values): array; + + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return TModel + * + * @throws ModelNotFoundException + * @throws MultipleRecordsFoundException + */ + public function sole(array|string $columns = ['*']): Model; + + /** + * Add a basic where clause to the query. + * + * @param array, mixed>|(Closure(($operator is null ? model-query-of : QueryBuilder)): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this + */ + public function where(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static; + + /** + * Add an "or where" clause to the query. + * + * A null operator passed as the second of two arguments becomes a comparison with null, which + * makes a callback a subquery, so the callback type cannot follow the operator here. + * + * @param array, mixed>|(Closure(__benevolent|QueryBuilder>): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this + */ + public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static; + + /** + * Add a basic where clause to the query, and return the first result. + * + * @param array, mixed>|(Closure(($operator is null ? model-query-of : QueryBuilder)): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return null|TModel + */ + public function firstWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model; + + /** + * Add a basic "where not" clause to the query. + * + * @param array, mixed>|(Closure(($operator is null ? model-query-of : QueryBuilder)): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this + */ + public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static; + + /** + * Add an "or where not" clause to the query. + * + * @param array, mixed>|(Closure(($operator is null ? model-query-of : QueryBuilder)): mixed)|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this + */ + public function orWhereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static; + + /** + * Specify attributes that should be added to any new models created by this builder. + * + * The given key / value pairs will also be added as where conditions to the query. + * + * @param array, mixed>|Expression|model-property $attributes + * @return $this + */ + public function withAttributes(Expression|array|string $attributes, mixed $value = null, bool $asConditions = true): static; + + /** + * Add an "order by" clause for a timestamp to the query. + * + * @param null|Closure|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this + */ + public function latest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static; + + /** + * Add an "order by" clause for a timestamp to the query. + * + * @param null|Closure|Builder<*>|QueryBuilder|Relation<*, *, *>|Expression|model-property $column + * @return $this + */ + public function oldest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static; + + /** + * Execute the query as a "select" statement. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return collection-of + */ + public function get(array|string $columns = ['*']); + + /** + * Get the hydrated models without eager loading. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return array + */ + public function getModels(array|string $columns = ['*']): array; + + /** + * Get a single column's value from the first result of a query. + * + * @param Expression|model-property $column + */ + public function value(Expression|string $column): mixed; + + /** + * Get a single column's value from the first result of a query if it's the sole matching record. + * + * @param Expression|model-property $column + * + * @throws ModelNotFoundException + * @throws MultipleRecordsFoundException + */ + public function soleValue(Expression|string $column): mixed; + + /** + * Get a single column's value from the first result of the query or throw an exception. + * + * @param Expression|model-property $column + * + * @throws ModelNotFoundException + */ + public function valueOrFail(Expression|string $column): mixed; + + /** + * Chunk the results of the query. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunk(int $count, callable $callback): bool; + + /** + * Chunk the results of a query by comparing IDs. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; + + /** + * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; + + /** + * Chunk the results of a query by comparing IDs in a given order. + * + * @param callable(collection-of, int): mixed $callback + */ + public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): bool; + + /** + * Increment a column's value by a given amount. + * + * @param Expression|model-property $column + * @param array, mixed> $extra + */ + public function increment(Expression|string $column, mixed $amount = 1, array $extra = []): int; + + /** + * Decrement a column's value by a given amount. + * + * @param Expression|model-property $column + * @param array, mixed> $extra + */ + public function decrement(Expression|string $column, mixed $amount = 1, array $extra = []): int; + + /** + * Increment the given column's values by the given amounts. + * + * @param array, float|int|numeric-string> $columns + * @param array, mixed> $extra + */ + public function incrementEach(array $columns, array $extra = []): int; + + /** + * Decrement the given column's values by the given amounts. + * + * @param array, float|int|numeric-string> $columns + * @param array, mixed> $extra + */ + public function decrementEach(array $columns, array $extra = []): int; + + /** + * Qualify the given column name by the model's table. + * + * @param Expression|model-property $column + */ + public function qualifyColumn(Expression|string $column): string; + + /** + * Qualify the given columns with the model's table. + * + * @param array> $columns + * @return array + */ + public function qualifyColumns(array $columns): array; +} diff --git a/src/phpstan/stubs/common/Facades.stub b/src/phpstan/stubs/common/Facades.stub new file mode 100644 index 0000000000..0aae99d23f --- /dev/null +++ b/src/phpstan/stubs/common/Facades.stub @@ -0,0 +1,99 @@ +, mixed> + */ + abstract public function definition(): array; + + /** + * Get a new factory instance for the given attributes. + * + * @param array, mixed>|(callable(array): array) $attributes + */ + public static function new(callable|array $attributes = []): static; + + /** + * Create a single model and persist it to the database. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return TModel + */ + public function createOne(callable|array $attributes = []): Model; + + /** + * Create a single model and persist it to the database without dispatching any model events. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return TModel + */ + public function createOneQuietly(callable|array $attributes = []): Model; + + /** + * Create a collection of models and persist them to the database. + * + * @param null|int|iterable, mixed>> $records + * @return collection-of + */ + public function createMany(int|iterable|null $records = null); + + /** + * Create a collection of models and persist them to the database without dispatching any model events. + * + * @param null|int|iterable, mixed>> $records + * @return collection-of + */ + public function createManyQuietly(int|iterable|null $records = null); + + /** + * Create a collection of models and persist them to the database. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return collection-of|TModel + */ + public function create(callable|array $attributes = [], ?Model $parent = null); + + /** + * Create a collection of models and persist them to the database without dispatching any model events. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return collection-of|TModel + */ + public function createQuietly(callable|array $attributes = [], ?Model $parent = null); + + /** + * Make a single instance of the model. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return TModel + */ + public function makeOne(callable|array $attributes = []): Model; + + /** + * Create a collection of models. + * + * @param array, mixed>|(callable(array): array) $attributes + * @return collection-of|TModel + */ + public function make(callable|array $attributes = [], ?Model $parent = null); + + /** + * Create a collection of models. + * + * @param null|int|iterable, mixed>> $records + * @return collection-of + */ + public function makeMany(iterable|int|null $records = null); +} diff --git a/src/phpstan/stubs/common/Foundation/Console/Kernel.stub b/src/phpstan/stubs/common/Foundation/Console/Kernel.stub new file mode 100644 index 0000000000..9bfbd738e4 --- /dev/null +++ b/src/phpstan/stubs/common/Foundation/Console/Kernel.stub @@ -0,0 +1,6 @@ + + */ +abstract class HasOneOrMany extends Relation +{ + /** + * Create and return an un-saved instance of the related model. + * + * @param array, mixed> $attributes + * @return TRelatedModel + */ + public function make(array $attributes = []): Model; + + /** + * Create and return an un-saved instance of the related models. + * + * @param iterable, mixed>> $records + * @return collection-of + */ + public function makeMany(iterable $records); + + /** + * Find a model by its primary key or return a new instance of the related model. + * + * @param array|Expression|'*'> $columns + * @return ($id is Model ? TRelatedModel : ($id is (array|Arrayable>) ? collection-of : TRelatedModel)) + */ + public function findOrNew(mixed $id, array $columns = ['*']); + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrFirst(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes, including trashed records, and restore it. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function restoreOrCreate(array $attributes = [], Closure|array $values = []): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, find the matching record, including trashed records, and restore it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrRestore(array $attributes = [], Closure|array $values = []): Model; + + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function updateOrCreate(array $attributes, Closure|array $values = []): Model; + + /** + * Create a related record matching the attributes, or increment the existing record. + * + * @param array, mixed> $attributes + * @param model-property $column + * @param array, mixed> $extra + * @return TRelatedModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model; + + /** + * Insert new records or update the existing ones. + * + * @param array, scalar|object|resource|null>|array, mixed>> $values + * @param model-property|array> $uniqueBy + * @param null|array, mixed> $update + */ + public function upsert(array $values, array|string $uniqueBy, ?array $update = null): int; + + /** + * Create a new instance of the related model. + * + * @param array, mixed> $attributes + * @return TRelatedModel + */ + public function create(array $attributes = []): Model; + + /** + * Create a new instance of the related model without raising any events to the parent model. + * + * @param array, mixed> $attributes + * @return TRelatedModel + */ + public function createQuietly(array $attributes = []): Model; + + /** + * Create a new instance of the related model. Allow mass-assignment. + * + * @param array, mixed> $attributes + * @return TRelatedModel + */ + public function forceCreate(array $attributes = []): Model; + + /** + * Create a new instance of the related model with mass assignment without raising model events. + * + * @param array, mixed> $attributes + * @return TRelatedModel + */ + public function forceCreateQuietly(array $attributes = []): Model; + + /** + * Create a Collection of new instances of the related model. + * + * @param iterable, mixed>> $records + * @return collection-of + */ + public function createMany(iterable $records); + + /** + * Create a Collection of new instances of the related model without raising any events to the parent model. + * + * @param iterable, mixed>> $records + * @return collection-of + */ + public function createManyQuietly(iterable $records); + + /** + * Create a Collection of new instances of the related model, allowing mass-assignment. + * + * @param iterable, mixed>> $records + * @return collection-of + */ + public function forceCreateMany(iterable $records); + + /** + * Create a Collection of new instances of the related model, allowing mass-assignment and without raising any events to the parent model. + * + * @param iterable, mixed>> $records + * @return collection-of + */ + public function forceCreateManyQuietly(iterable $records); +} diff --git a/src/phpstan/stubs/common/HasOneOrManyThrough.stub b/src/phpstan/stubs/common/HasOneOrManyThrough.stub new file mode 100644 index 0000000000..43bb609f9a --- /dev/null +++ b/src/phpstan/stubs/common/HasOneOrManyThrough.stub @@ -0,0 +1,233 @@ + + */ +abstract class HasOneOrManyThrough extends Relation +{ + /** + * Prepare the query builder for query execution. + * + * @param array|Expression|'*'> $columns + * @return builder-of + */ + protected function prepareQueryBuilder(array $columns = ['*']): Builder; + + /** + * Add the constraints for an internal relationship existence query. + * + * Unlike Laravel, which inherits Relation's types, the parent query is the declaring model's: Relation's + * parent here is the intermediate model. + * + * @template TQuery of Builder + * @template TParentQuery of Builder + * + * @param TQuery $query + * @param TParentQuery $parentQuery + * @param array|Expression|'*'>|model-property|Expression|'*' $columns + * @return TQuery + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder; + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrFirst(array $attributes = [], Closure|array $values = []): Model; + + /** + * Get the first record matching the attributes, including trashed records, and restore it. If the record is not found, create it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function restoreOrCreate(array $attributes = [], Closure|array $values = []): Model; + + /** + * Attempt to create the record. If a unique constraint violation occurs, find the matching record, including trashed records, and restore it. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrRestore(array $attributes = [], Closure|array $values = []): Model; + + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @param array, mixed> $attributes + * @param array, mixed>|(Closure(): array, mixed>) $values + * @return TRelatedModel + */ + public function updateOrCreate(array $attributes, Closure|array $values = []): Model; + + /** + * Create a related record matching the attributes, or increment the existing record. + * + * @param array, mixed> $attributes + * @param model-property $column + * @param array, mixed> $extra + * @return TRelatedModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model; + + /** + * Add a basic where clause to the query, and return the first result. + * + * @param array, mixed>|(Closure(($operator is null ? model-query-of : QueryBuilder)): mixed)|model-property $column + * @return null|TRelatedModel + */ + public function firstWhere(Closure|string|array $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model; + + /** + * Execute the query and get the first related model. + * + * @param array|Expression|'*'> $columns + * @return null|TRelatedModel + */ + public function first(array $columns = ['*']): ?Model; + + /** + * Execute the query and get the first result or throw an exception. + * + * @param array|Expression|'*'> $columns + * @return TRelatedModel + * + * @throws ModelNotFoundException + */ + public function firstOrFail(array $columns = ['*']): Model; + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|Expression|'*'> $columns + * @param null|(Closure(): TValue) $callback + * @return TRelatedModel|TValue + */ + public function firstOr(Closure|array $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Find a related model by its primary key. + * + * @param array|Expression|'*'> $columns + * @return ($id is Model ? null|TRelatedModel : ($id is (array|Arrayable>) ? collection-of : null|TRelatedModel)) + */ + public function find(mixed $id, array $columns = ['*']); + + /** + * Find multiple related models by their primary keys. + * + * @param array|Arrayable> $ids + * @param array|Expression|'*'> $columns + * @return collection-of + */ + public function findMany(Arrayable|array $ids, array $columns = ['*']); + + /** + * Find a related model by its primary key or throw an exception. + * + * @param array|Expression|'*'> $columns + * @return ($id is Model ? TRelatedModel : ($id is (array|Arrayable>) ? collection-of : TRelatedModel)) + * + * @throws ModelNotFoundException + */ + public function findOrFail(mixed $id, array $columns = ['*']); + + /** + * Find a related model by its primary key or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|Expression|'*'>|model-property|'*' $columns + * @param null|(Closure(): TValue) $callback + * @return ( + * $id is Model ? TRelatedModel|TValue : ( + * $id is (Arrayable>|array) + * ? collection-of|TValue + * : TRelatedModel|TValue + * ) + * ) + */ + public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closure $callback = null): mixed; + + /** + * Execute the query as a "select" statement. + * + * @param array|Expression|'*'> $columns + * @return collection-of + */ + public function get(array $columns = ['*']); + + /** + * Chunk the results of the query. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunk(int $count, callable $callback): bool; + + /** + * Chunk the results of a query by comparing numeric IDs. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; + + /** + * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(collection-of, int): mixed $callback + */ + public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool; +} diff --git a/src/phpstan/stubs/common/Helpers.stub b/src/phpstan/stubs/common/Helpers.stub new file mode 100644 index 0000000000..676bb62778 --- /dev/null +++ b/src/phpstan/stubs/common/Helpers.stub @@ -0,0 +1,32 @@ + $data + * @param array $mergeData + */ +function view(?string $view = null, array|Arrayable $data = [], array $mergeData = []): ViewFactory|ViewContract {} + +// Upstream's optional() stub is omitted: it types an Optional as mixed to accept any method call, +// while the source declares Optional, whose calls OptionalExtension passes to the value. + +// Upstream's trans() and __() are omitted: the source already declares their conditional +// return, and upstream's replacement type rejects objects Hypervel's translator accepts. + +// Upstream's blank(), filled(), rescue(), retry(), tap() and transform() stubs and its TapExtension +// are omitted: the source declares their types. + +// Upstream's cookie(), logger(), logs(), redirect(), request(), session() and url() stubs are omitted: +// the source declares their conditional returns. + +// Upstream's config() and env() stubs and EnvFunctionDynamicFunctionReturnTypeExtension are omitted: +// the source declares their types, and an env() default does not decide the type of the value read. + +// Upstream's NowAndTodayExtension and DateExtension are omitted: they type dates as the class of +// now() when analysis runs, while now(), today() and the Date facade declare CarbonInterface. diff --git a/src/phpstan/stubs/common/Http/PendingRequest.stub b/src/phpstan/stubs/common/Http/PendingRequest.stub new file mode 100644 index 0000000000..e487b57839 --- /dev/null +++ b/src/phpstan/stubs/common/Http/PendingRequest.stub @@ -0,0 +1,8 @@ + $data + */ + protected function view(string $view, Arrayable|array $data = []): TestView; +} diff --git a/src/phpstan/stubs/common/Log/Logger.stub b/src/phpstan/stubs/common/Log/Logger.stub new file mode 100644 index 0000000000..100560d0b8 --- /dev/null +++ b/src/phpstan/stubs/common/Log/Logger.stub @@ -0,0 +1,6 @@ + $with + */ + public function __construct(?string $view = null, ?string $html = null, ?string $text = null, ?string $markdown = null, array $with = [], ?string $htmlString = null); +} diff --git a/src/phpstan/stubs/common/MailMessage.stub b/src/phpstan/stubs/common/MailMessage.stub new file mode 100644 index 0000000000..7cae79becf --- /dev/null +++ b/src/phpstan/stubs/common/MailMessage.stub @@ -0,0 +1,27 @@ + $data + * @return $this + */ + public function markdown(string $view, array $data = []): static; + + /** + * @param view-string|view-string[] $view + * @param array $data + * @return $this + */ + public function view(array|string $view, array $data = []): static; + + /** + * @param view-string $textView + * @param array $data + * @return $this + */ + public function text(string $textView, array $data = []): static; +} diff --git a/src/phpstan/stubs/common/Mailable.stub b/src/phpstan/stubs/common/Mailable.stub new file mode 100644 index 0000000000..4f656b37fb --- /dev/null +++ b/src/phpstan/stubs/common/Mailable.stub @@ -0,0 +1,27 @@ + $data + * @return $this + */ + public function markdown(string $view, array $data = []): static; + + /** + * @param view-string $view + * @param array $data + * @return $this + */ + public function view(string $view, array $data = []): static; + + /** + * @param view-string $textView + * @param array $data + * @return $this + */ + public function text(string $textView, array $data = []): static; +} diff --git a/src/phpstan/stubs/common/Model.stub b/src/phpstan/stubs/common/Model.stub new file mode 100644 index 0000000000..0f5cb55f84 --- /dev/null +++ b/src/phpstan/stubs/common/Model.stub @@ -0,0 +1,225 @@ +, mixed> $attributes + * @param array{touch?: null|bool, ...} $options + * @return bool + */ + public function update(array $attributes = [], array $options = []): bool; + + /** + * Fill the model with an array of attributes. + * + * @param array, mixed> $attributes + * @return $this + * + * @throws MassAssignmentException + */ + public function fill(array $attributes): static; + + /** + * Fill the model with an array of attributes. Force mass assignment. + * + * @param array, mixed> $attributes + * @return $this + */ + public function forceFill(array $attributes): static; + + /** @return builder-of */ + public static function query(): Builder; + + /** @return builder-of */ + public function newQuery(): Builder; + + /** @return model-query-of */ + public function newModelQuery(): Builder; + + /** @return model-query-of */ + public function newQueryWithoutRelationships(): Builder; + + /** @return model-query-of */ + public function newQueryWithoutScopes(): Builder; + + /** @return builder-of */ + public function newQueryWithoutScope(Scope|int|string $scope): Builder; + + /** + * @param array|int|string $ids + * @return model-query-of + */ + public function newQueryForRestoration(array|int|string $ids): Builder; + + /** + * Begin querying a model with eager loading. + * + * @template TRelations of array + * @param eager-loads|string $relations + * @return builder-of + */ + public static function with(array|string $relations): Builder; + + /** + * Eager load relations on the model. + * + * @template TRelations of array + * @param eager-loads|string $relations + * @return $this + */ + public function load(array|string $relations): static; + + /** + * Eager load relationships on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-eager-loads $relations + * @return $this + */ + public function loadMorph(string $relation, array $relations): static; + + /** + * Eager load relations on the model if they are not already eager loaded. + * + * @template TRelations of array + * @param eager-loads|string $relations + * @return $this + */ + public function loadMissing(array|string $relations): static; + + /** + * Eager load relation's column aggregations on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadAggregate(array|string $relations, Expression|string $column, ?string $function = null): static; + + /** + * Eager load relation counts on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadCount(array|string $relations): static; + + /** + * Eager load relation max column values on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadMax(array|string $relations, Expression|string $column): static; + + /** + * Eager load relation min column values on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadMin(array|string $relations, Expression|string $column): static; + + /** + * Eager load relation's column summations on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadSum(array|string $relations, Expression|string $column): static; + + /** + * Eager load relation average column values on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadAvg(array|string $relations, Expression|string $column): static; + + /** + * Eager load related model existence values on the model. + * + * @template TRelations of array + * @param relation-aggregates|string $relations + * @return $this + */ + public function loadExists(array|string $relations): static; + + /** + * Eager load relationship column aggregation on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphAggregate(string $relation, array $relations, Expression|string $column, ?string $function = null): static; + + /** + * Eager load relationship counts on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphCount(string $relation, array $relations): static; + + /** + * Eager load relationship max column values on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphMax(string $relation, array $relations, Expression|string $column): static; + + /** + * Eager load relationship min column values on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphMin(string $relation, array $relations, Expression|string $column): static; + + /** + * Eager load relationship column summations on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphSum(string $relation, array $relations, Expression|string $column): static; + + /** + * Eager load relationship average column values on the polymorphic relation of a model. + * + * @template TRelations of array + * @param morph-aggregates $relations + * @return $this + */ + public function loadMorphAvg(string $relation, array $relations, Expression|string $column): static; + + /** + * Reload a fresh model instance from the database. + * + * @template TRelations of array + * @param eager-loads|string $with + */ + public function fresh(array|string $with = []): ?static; +} diff --git a/src/phpstan/stubs/common/Queue/Queue.stub b/src/phpstan/stubs/common/Queue/Queue.stub new file mode 100644 index 0000000000..4a5af51c0c --- /dev/null +++ b/src/phpstan/stubs/common/Queue/Queue.stub @@ -0,0 +1,7 @@ + parameter would reject. + */ + +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @template TResult + * + * @mixin Builder + */ +abstract class Relation +{ + /** + * Execute the query as a "select" statement. + * + * @param array|Expression|'*'> $columns + * @return collection-of + */ + public function get(array $columns = ['*']); + + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @param array|Expression|'*'>|model-property|'*' $columns + * @return TRelatedModel + * + * @throws ModelNotFoundException + * @throws MultipleRecordsFoundException + */ + public function sole(array|string $columns = ['*']): Model; + + /** + * Add the constraints for a relationship count query. + * + * @template TQuery of Builder + * @template TParentQuery of Builder + * + * @param TQuery $query + * @param TParentQuery $parentQuery + * @return TQuery + */ + public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery): Builder; + + /** + * Add the constraints for an internal relationship existence query. + * + * Essentially, these queries compare on column names like whereColumn. + * + * @template TQuery of Builder + * @template TParentQuery of Builder + * + * @param TQuery $query + * @param TParentQuery $parentQuery + * @param array|Expression|'*'>|model-property|Expression|'*' $columns + * @return TQuery + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder; + + /** + * Get the query builder that will contain the relationship constraints. + * + * @return builder-of + */ + protected function getRelationQuery(): Builder; + + /** + * Get the underlying query for the relation. + * + * @return builder-of + */ + public function getQuery(): Builder; +} diff --git a/src/phpstan/stubs/common/Route.stub b/src/phpstan/stubs/common/Route.stub new file mode 100644 index 0000000000..10b78c9340 --- /dev/null +++ b/src/phpstan/stubs/common/Route.stub @@ -0,0 +1,6 @@ + $data + * @param int|array $status an array is taken as the headers + * @param array $headers + */ + public function view(string $uri, string $view, array $data = [], int|array $status = 200, array $headers = []): Route; +} diff --git a/src/phpstan/stubs/common/SortDirection.stub b/src/phpstan/stubs/common/SortDirection.stub new file mode 100644 index 0000000000..b2a15fc89b --- /dev/null +++ b/src/phpstan/stubs/common/SortDirection.stub @@ -0,0 +1,8 @@ +|Closure|string $callback + * @return $this */ public function assertRan(Closure|array|string $callback): static { @@ -178,6 +187,7 @@ public function assertRan(Closure|array|string $callback): static * Assert that a process was recorded a given number of times matching a given truth test. * * @param array|Closure|string $callback + * @return $this */ public function assertRanTimes(Closure|array|string $callback, int $times = 1): static { @@ -200,6 +210,7 @@ public function assertRanTimes(Closure|array|string $callback, int $times = 1): * Assert that the given processes were run in the given order. * * @param list|Closure|string> $callbacks + * @return $this */ public function assertRanInOrder(array $callbacks): static { @@ -221,6 +232,8 @@ public function assertRanInOrder(array $callbacks): static /** * Assert how many processes have been recorded. + * + * @return $this */ protected function assertRanCount(int $count): static { @@ -233,6 +246,7 @@ protected function assertRanCount(int $count): static * Assert that a process was not recorded matching a given truth test. * * @param array|Closure|string $callback + * @return $this */ public function assertNotRan(Closure|array|string $callback): static { @@ -252,6 +266,7 @@ public function assertNotRan(Closure|array|string $callback): static * Assert that a process was not recorded matching a given truth test. * * @param array|Closure|string $callback + * @return $this */ public function assertDidntRun(Closure|array|string $callback): static { @@ -260,6 +275,8 @@ public function assertDidntRun(Closure|array|string $callback): static /** * Assert that no processes were recorded. + * + * @return $this */ public function assertNothingRan(): static { diff --git a/src/process/src/FakeInvokedProcess.php b/src/process/src/FakeInvokedProcess.php index ae21c3989e..87888472a6 100644 --- a/src/process/src/FakeInvokedProcess.php +++ b/src/process/src/FakeInvokedProcess.php @@ -74,6 +74,8 @@ public function command(): string /** * Send a signal to the process. + * + * @return $this */ public function signal(int $signal): static { @@ -324,6 +326,8 @@ public function predictProcessResult(): ProcessResultContract /** * Set the general output handler for the fake invoked process. + * + * @return $this */ public function withOutputHandler(?callable $outputHandler): static { diff --git a/src/process/src/FakeProcessDescription.php b/src/process/src/FakeProcessDescription.php index b09fcbbf53..4893ee62cf 100644 --- a/src/process/src/FakeProcessDescription.php +++ b/src/process/src/FakeProcessDescription.php @@ -34,6 +34,8 @@ class FakeProcessDescription /** * Specify the process ID that should be assigned to the process. + * + * @return $this */ public function id(int $processId): static { @@ -44,6 +46,8 @@ public function id(int $processId): static /** * Describe a line of standard output. + * + * @return $this */ public function output(array|string $output): static { @@ -60,6 +64,8 @@ public function output(array|string $output): static /** * Describe a line of error output. + * + * @return $this */ public function errorOutput(array|string $output): static { @@ -76,6 +82,8 @@ public function errorOutput(array|string $output): static /** * Replace the entire output buffer with the given string. + * + * @return $this */ public function replaceOutput(string $output): static { @@ -96,6 +104,8 @@ public function replaceOutput(string $output): static /** * Replace the entire error output buffer with the given string. + * + * @return $this */ public function replaceErrorOutput(string $output): static { @@ -116,6 +126,8 @@ public function replaceErrorOutput(string $output): static /** * Specify the process exit code. + * + * @return $this */ public function exitCode(int $exitCode): static { @@ -126,6 +138,8 @@ public function exitCode(int $exitCode): static /** * Specify how many times the "isRunning" method should return "true". + * + * @return $this */ public function iterations(int $iterations): static { @@ -134,6 +148,8 @@ public function iterations(int $iterations): static /** * Specify how many times the "isRunning" method should return "true". + * + * @return $this */ public function runsFor(int $iterations): static { diff --git a/src/process/src/FakeProcessResult.php b/src/process/src/FakeProcessResult.php index 37ab6d0908..4e1e53068b 100644 --- a/src/process/src/FakeProcessResult.php +++ b/src/process/src/FakeProcessResult.php @@ -132,6 +132,8 @@ public function seeInErrorOutput(string $output): bool /** * Throw an exception if the process failed. * + * @return $this + * * @throws ProcessFailedException */ public function throw(?callable $callback = null): static @@ -152,6 +154,8 @@ public function throw(?callable $callback = null): static /** * Throw an exception if the process failed and the given condition is true. * + * @return $this + * * @throws ProcessFailedException */ public function throwIf(bool $condition, ?callable $callback = null): static diff --git a/src/process/src/FakeProcessSequence.php b/src/process/src/FakeProcessSequence.php index fb91cc7ba2..5ce366788c 100644 --- a/src/process/src/FakeProcessSequence.php +++ b/src/process/src/FakeProcessSequence.php @@ -36,6 +36,8 @@ public function __construct(protected array $processes = []) /** * Push a new process result or description onto the sequence. + * + * @return $this */ public function push(array|FakeProcessDescription|ProcessResultContract|string $process): static { @@ -46,6 +48,8 @@ public function push(array|FakeProcessDescription|ProcessResultContract|string $ /** * Make the sequence return a default result when it is empty. + * + * @return $this */ public function whenEmpty(array|FakeProcessDescription|ProcessResultContract|string $process): static { @@ -67,6 +71,8 @@ protected function toProcessResult(array|FakeProcessDescription|ProcessResultCon /** * Make the sequence return a default result when it is empty. + * + * @return $this */ public function dontFailWhenEmpty(): static { @@ -93,7 +99,7 @@ public function __invoke(): FakeProcessDescription|ProcessResultContract } if (! $this->failWhenEmpty && count($this->processes) === 0) { - return value($this->emptyProcess ?? new FakeProcessResult); + return $this->emptyProcess ?? new FakeProcessResult; } return array_shift($this->processes); diff --git a/src/process/src/InvokedProcess.php b/src/process/src/InvokedProcess.php index 3c7550a494..b6e906c020 100644 --- a/src/process/src/InvokedProcess.php +++ b/src/process/src/InvokedProcess.php @@ -47,6 +47,8 @@ public function command(): string /** * Send a signal to the process. + * + * @return $this */ public function signal(int $signal): static { diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index f9aa57bd9b..78543e35be 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -103,6 +103,8 @@ public function __construct(Factory $factory) /** * Specify the command that will invoke the process. + * + * @return $this */ public function command(array|string $command): static { @@ -113,6 +115,8 @@ public function command(array|string $command): static /** * Specify the working directory of the process. + * + * @return $this */ public function path(string $path): static { @@ -123,6 +127,8 @@ public function path(string $path): static /** * Specify the maximum number of seconds the process may run. + * + * @return $this */ public function timeout(CarbonInterval|int $timeout): static { @@ -133,6 +139,8 @@ public function timeout(CarbonInterval|int $timeout): static /** * Specify the maximum number of seconds a process may go without returning output. + * + * @return $this */ public function idleTimeout(CarbonInterval|int $timeout): static { @@ -143,6 +151,8 @@ public function idleTimeout(CarbonInterval|int $timeout): static /** * Indicate that the process may run forever without timing out. + * + * @return $this */ public function forever(): static { @@ -155,6 +165,7 @@ public function forever(): static * Set the additional environment variables for the process. * * @param array $environment + * @return $this */ public function env(array $environment): static { @@ -167,6 +178,7 @@ public function env(array $environment): static * Set the standard input that should be provided when invoking the process. * * @param null|bool|float|int|resource|string|Traversable $input + * @return $this */ public function input(mixed $input): static { @@ -177,6 +189,8 @@ public function input(mixed $input): static /** * Disable output for the process. + * + * @return $this */ public function quietly(): static { @@ -187,6 +201,8 @@ public function quietly(): static /** * Enable TTY mode for the process. + * + * @return $this */ public function tty(bool $tty = true): static { @@ -199,6 +215,7 @@ public function tty(bool $tty = true): static * Set the "proc_open" options that should be used when invoking the process. * * @param array $options + * @return $this */ public function options(array $options): static { @@ -230,7 +247,7 @@ public function run(array|string|null $command = null, ?callable $output = null) ConnectionResolver::releaseIdleConnections(); - return new ProcessResult(tap($process)->run($this->guardProcessOutput($process, $output))); // @phpstan-ignore method.notFound (tap proxy __call) + return new ProcessResult(tap($process)->run($this->guardProcessOutput($process, $output))); } catch (SymfonyTimeoutException $e) { throw ProcessTimedOutException::make($e, new ProcessResult($process)); } catch (Throwable $exception) { @@ -337,6 +354,7 @@ public function supportsTty(): bool * Specify the fake process result handlers for the pending process. * * @param array $fakeHandlers + * @return $this */ public function withFakeHandlers(array $fakeHandlers): static { diff --git a/src/process/src/ProcessResult.php b/src/process/src/ProcessResult.php index 16bd5f75a3..ce00f6b91a 100644 --- a/src/process/src/ProcessResult.php +++ b/src/process/src/ProcessResult.php @@ -94,6 +94,8 @@ public function seeInErrorOutput(string $output): bool /** * Throw an exception if the process failed. * + * @return $this + * * @throws ProcessFailedException */ public function throw(?callable $callback = null): static @@ -114,6 +116,8 @@ public function throw(?callable $callback = null): static /** * Throw an exception if the process failed and the given condition is true. * + * @return $this + * * @throws ProcessFailedException */ public function throwIf(bool $condition, ?callable $callback = null): static diff --git a/src/prompts/src/FormBuilder.php b/src/prompts/src/FormBuilder.php index f60c4d1a83..6d4717dad6 100644 --- a/src/prompts/src/FormBuilder.php +++ b/src/prompts/src/FormBuilder.php @@ -99,8 +99,10 @@ public function text(string $label, string $placeholder = '', string $default = /** * Prompt the user for multiline text input. + * + * Unlike Laravel's ?Closure, $validate accepts validation rules, as the textarea() helper and the other prompts do. */ - public function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, ?Closure $validate = null, string $hint = '', int $rows = 5, ?string $name = null, ?Closure $transform = null): self + public function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = '', int $rows = 5, ?string $name = null, ?Closure $transform = null): self { return $this->runPrompt(textarea(...), get_defined_vars()); } diff --git a/src/prompts/src/Progress.php b/src/prompts/src/Progress.php index 420696b19f..0153cbbe53 100644 --- a/src/prompts/src/Progress.php +++ b/src/prompts/src/Progress.php @@ -89,6 +89,7 @@ public function map(Closure $callback): array if ($this->hint !== '') { // Just pause for one moment to show the final hint // so it doesn't look like it was skipped + // @phpstan-ignore hypervel.rawSleep (prompts does not depend on hypervel/support) usleep(250_000); } } catch (Throwable $e) { @@ -210,7 +211,7 @@ private function settleOperation(?Throwable $failure = null): ?Throwable try { $this->restoreTerminalState(); } catch (Throwable $exception) { - $failure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } return $failure; diff --git a/src/prompts/src/Prompt.php b/src/prompts/src/Prompt.php index 58f99be76c..ffc39ef0ff 100644 --- a/src/prompts/src/Prompt.php +++ b/src/prompts/src/Prompt.php @@ -352,7 +352,7 @@ protected function restoreTerminalState(): void // The Terminal retains its mode on failure so the same controlling TTY can be retried. static::terminal()->restoreTty(); } catch (Throwable $exception) { - $failure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } if ($failure !== null) { diff --git a/src/prompts/src/Spinner.php b/src/prompts/src/Spinner.php index edf8f28c6c..5fded63724 100644 --- a/src/prompts/src/Spinner.php +++ b/src/prompts/src/Spinner.php @@ -145,7 +145,7 @@ private function settleOperation(): ?Throwable try { $this->restoreTerminalState(); } catch (Throwable $exception) { - $failure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } return $failure; diff --git a/src/prompts/src/Stream.php b/src/prompts/src/Stream.php index 95f06ee79a..389b703a4b 100644 --- a/src/prompts/src/Stream.php +++ b/src/prompts/src/Stream.php @@ -5,6 +5,7 @@ namespace Hypervel\Prompts; use Closure; +use Hypervel\Coroutine\Coroutine; use Hypervel\Prompts\Themes\Default\Concerns\InteractsWithStrings; use RuntimeException; use Symfony\Component\Console\Output\OutputInterface; @@ -85,6 +86,7 @@ public function close(): void while (count($this->currentlyFading) > 0) { $this->message .= array_shift($this->currentlyFading); $this->render(); + // @phpstan-ignore hypervel.rawSleep (prompts does not depend on hypervel/support) usleep(25_000); } } catch (Throwable $exception) { @@ -94,7 +96,7 @@ public function close(): void try { $this->restoreTerminalState(); } catch (Throwable $exception) { - $failure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } if ($failure !== null) { diff --git a/src/prompts/src/Support/Logger.php b/src/prompts/src/Support/Logger.php index 6d25de731f..d33df0b49e 100644 --- a/src/prompts/src/Support/Logger.php +++ b/src/prompts/src/Support/Logger.php @@ -117,7 +117,7 @@ protected function write(string $message, ?string $type = null): void try { Utils::writeAll($this->socket, $frame, $this->writeTimeout); } catch (RuntimeException $exception) { - $this->transportFailure ??= $exception; + $this->transportFailure = $exception; $this->socket = null; } } diff --git a/src/prompts/src/Task.php b/src/prompts/src/Task.php index 036612df94..370105b1ff 100644 --- a/src/prompts/src/Task.php +++ b/src/prompts/src/Task.php @@ -534,6 +534,7 @@ protected function runRendererProcess($socket): never $this->render(); ++$this->count; + // @phpstan-ignore hypervel.rawSleep (prompts does not depend on hypervel/support) usleep($this->interval * 1000); } @@ -590,13 +591,13 @@ private function restoreProcessState(?Throwable $failure = null): ?Throwable try { $this->restoreSignalState(); } catch (Throwable $exception) { - $failure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } try { $this->restoreTerminalState(); } catch (Throwable $exception) { - $failure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } return $failure; @@ -813,7 +814,7 @@ private function settleOperation(bool $renderFinalFrame = false, bool $success = try { $this->restoreTerminalState(); } catch (Throwable $exception) { - $failure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } return $failure; diff --git a/src/prompts/src/Themes/Default/Concerns/DrawsScrollbars.php b/src/prompts/src/Themes/Default/Concerns/DrawsScrollbars.php index 78081cb02c..9e0f2b8e3c 100644 --- a/src/prompts/src/Themes/Default/Concerns/DrawsScrollbars.php +++ b/src/prompts/src/Themes/Default/Concerns/DrawsScrollbars.php @@ -33,7 +33,7 @@ protected function scrollbar(array|Collection $visible, int $firstVisible, int $ default => $this->replaceLastVisibleGrapheme($this->pad($line, $width), $this->gray('│')), }, array_values($lines), range(0, count($lines) - 1)); - return $visible instanceof Collection ? new Collection($result) : $result; // @phpstan-ignore return.type (https://github.com/phpstan/phpstan/issues/11663) + return $visible instanceof Collection ? new Collection($result) : $result; } /** diff --git a/src/queue/src/CallQueuedClosure.php b/src/queue/src/CallQueuedClosure.php index a8791377a2..b24d84eb31 100644 --- a/src/queue/src/CallQueuedClosure.php +++ b/src/queue/src/CallQueuedClosure.php @@ -35,6 +35,7 @@ class CallQueuedClosure implements ShouldQueue /** * Indicate if the job should be deleted when models are missing. */ + // @phpstan-ignore hypervel.legacyQueueProperty (public property; removing it would leave assignments to it without declared storage) public bool $deleteWhenMissingModels = true; /** @@ -63,6 +64,8 @@ public function handle(Container $container): void /** * Add a callback to be executed if the job fails. + * + * @return $this */ public function onFailure(callable $callback): static { @@ -97,6 +100,8 @@ public function displayName(): string /** * Assign a name to the job. + * + * @return $this */ public function name(string $name): static { diff --git a/src/queue/src/Concerns/StagesOutboxJobs.php b/src/queue/src/Concerns/StagesOutboxJobs.php index 4dd932ff9a..e7ec94033b 100644 --- a/src/queue/src/Concerns/StagesOutboxJobs.php +++ b/src/queue/src/Concerns/StagesOutboxJobs.php @@ -8,7 +8,6 @@ use DateTimeInterface; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Queue\ShouldQueueViaOutbox; -use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\InvalidPayloadException; @@ -97,19 +96,18 @@ protected function resolveOutboxCommitConnection(DatabaseTransactionsManager $tr */ protected function enqueueViaOutbox(object|string $job, string $payload, ?string $queue, DateInterval|DateTimeInterface|int|null $delay): string { + $sendAttempted = false; + try { $dispatcher = $this->container->make(OutboxDispatcher::class); $connection = $dispatcher->databaseFor($job); $payload = $this->finalizePayloadForQueueing($queue, $job, $payload, $delay); $row = $this->prepareOutboxRow($job, $payload, $queue, $delay); $this->raiseJobQueueingEvent($queue, $job, $payload, $delay); + $sendAttempted = true; $dispatcher->store($connection, [$row], [$job]); } catch (Throwable $exception) { - if (Coroutine::causedByCancellation($exception)) { - throw $exception; - } - - $this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception); + $this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception, $sendAttempted); throw $exception; } @@ -142,6 +140,7 @@ protected function stageOutboxJobs(array $jobs, mixed $data, ?string $queue): ar $dispatcher = $this->container->make(OutboxDispatcher::class); $prepared = $this->prepareBatchJobs($selected, $data, $queue); $groups = []; + $attempted = []; try { // Resolve every owner before finalization or insertion of any group. @@ -169,6 +168,7 @@ protected function stageOutboxJobs(array $jobs, mixed $data, ?string $queue): ar $this->raiseJobQueueingEvent($queue, $member['job'], $member['payload'], $member['delay']); } + $attempted = array_fill_keys($group['members'], true); $dispatcher->store($group['connection'], $rows, $groupJobs); $accepted = []; @@ -189,12 +189,10 @@ protected function stageOutboxJobs(array $jobs, mixed $data, ?string $queue): ar } } } catch (Throwable $exception) { - if (Coroutine::causedByCancellation($exception)) { - throw $exception; - } - - $this->notifyBatchMembersOfFailure($prepared, function (array $member) use ($queue, $exception): void { - $this->raiseJobQueueingFailedEvent($queue, $member['job'], $member['payload'], $member['delay'], $exception); + // Stored groups were removed, so only the group whose store failed was attempted. + $this->notifyBatchMembersOfFailure(array_keys($prepared), function (int $index) use ($prepared, $attempted, $queue, $exception): void { + $member = $prepared[$index]; + $this->raiseJobQueueingFailedEvent($queue, $member['job'], $member['payload'], $member['delay'], $exception, isset($attempted[$index])); }); throw $exception; diff --git a/src/queue/src/Console/ForgetOutboxCommand.php b/src/queue/src/Console/ForgetOutboxCommand.php index f0a35fa599..0d6e632b28 100644 --- a/src/queue/src/Console/ForgetOutboxCommand.php +++ b/src/queue/src/Console/ForgetOutboxCommand.php @@ -10,10 +10,10 @@ use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Coroutine\Coroutine; use Hypervel\Queue\Console\Concerns\FindsOutboxMessages; use Hypervel\Queue\Outbox\DatabaseOutboxRepository; use LogicException; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Attribute\AsCommand; use Throwable; @@ -71,9 +71,11 @@ public function handle(): int try { $this->releaseUniqueLock($message->payload); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->components->error("Outbox message [{$uuid}] was deleted, but unique-lock cleanup failed: {$exception->getMessage()}"); $this->exceptions->report($exception, ['outbox_database' => $found['database'], 'job_uuid' => $uuid]); diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index ad0969ab1c..48ab59476d 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -92,7 +92,7 @@ public function __construct( public function handle(): ?int { if ($this->downForMaintenance() && $this->option('once')) { - return $this->worker->sleep((float) $this->option('sleep')); // @phpstan-ignore method.void + return $this->worker->sleep((float) $this->option('sleep')); // @phpstan-ignore method.void (returns the void call's null, as upstream) } // We'll listen to the processed and failed events so we can write information diff --git a/src/queue/src/CoroutineQueue.php b/src/queue/src/CoroutineQueue.php index c093c65f99..150984d74c 100644 --- a/src/queue/src/CoroutineQueue.php +++ b/src/queue/src/CoroutineQueue.php @@ -142,6 +142,7 @@ protected function executePayload(string $payload, ?string $queue = null): int $this->withLogContext(null, function () use ($payload, $queue): void { try { $this->processPayload($payload, $queue); + // @phpstan-ignore hypervel.cancellation.exactType (native cancellation ends the job silently; a cleanup failure carrying it is reported) } catch (CanceledException $exception) { throw $exception; } catch (Throwable $e) { diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 6a509dcdbb..78b6cd40a4 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -390,6 +390,8 @@ protected function prepareBatchJobs(array $jobs, mixed $data, ?string $queue): a */ protected function enqueueBatch(array $jobs, ?string $queue): mixed { + $sendAttempted = false; + try { foreach ($jobs as $index => $job) { $jobs[$index]['payload'] = $this->finalizePayloadForQueueing( @@ -409,6 +411,7 @@ protected function enqueueBatch(array $jobs, ?string $queue): mixed $now = $this->availableAt(); $connection = $this->getDatabase(); + $sendAttempted = true; $this->insertDatabaseRows( $connection, @@ -427,13 +430,9 @@ protected function enqueueBatch(array $jobs, ?string $queue): mixed $result = true; } catch (Throwable $exception) { - if (Coroutine::causedByCancellation($exception)) { - throw $exception; - } - $this->notifyBatchMembersOfFailure( $jobs, - fn (array $job) => $this->raiseJobQueueingFailedEvent($queue, $job['job'], $job['payload'], $job['delay'], $exception), + fn (array $job) => $this->raiseJobQueueingFailedEvent($queue, $job['job'], $job['payload'], $job['delay'], $exception, $sendAttempted), ); throw $exception; diff --git a/src/queue/src/Events/JobAttempted.php b/src/queue/src/Events/JobAttempted.php index be3efc8b17..530d9ab1d6 100644 --- a/src/queue/src/Events/JobAttempted.php +++ b/src/queue/src/Events/JobAttempted.php @@ -14,12 +14,14 @@ class JobAttempted * * @param null|Throwable $exception the exception, if one occurred while processing the job * @param null|float $duration the number of milliseconds the job took to process + * @param bool $canceled whether cancellation stopped the attempt, or its timeout monitor took it over, before its normal completion */ public function __construct( public string $connectionName, public Job $job, public ?Throwable $exception = null, public ?float $duration = null, + public bool $canceled = false, ) { } @@ -28,6 +30,6 @@ public function __construct( */ public function successful(): bool { - return ! $this->job->hasFailed() && is_null($this->exception); + return ! $this->job->hasFailed() && is_null($this->exception) && ! $this->canceled; } } diff --git a/src/queue/src/Events/JobQueueingCanceled.php b/src/queue/src/Events/JobQueueingCanceled.php new file mode 100644 index 0000000000..da62cc74b2 --- /dev/null +++ b/src/queue/src/Events/JobQueueingCanceled.php @@ -0,0 +1,49 @@ +payload, true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/src/queue/src/Events/JobQueueingFailed.php b/src/queue/src/Events/JobQueueingFailed.php index a62f5ea6a7..a4b0df2a64 100644 --- a/src/queue/src/Events/JobQueueingFailed.php +++ b/src/queue/src/Events/JobQueueingFailed.php @@ -15,6 +15,7 @@ class JobQueueingFailed * * @param Closure|object|string $job * @param null|int $delay the number of seconds the job was delayed + * @param bool $sendAttempted whether the job was handed to storage or the broker before it failed */ public function __construct( public string $connectionName, @@ -22,7 +23,8 @@ public function __construct( public object|string $job, public string $payload, public ?int $delay, - public Throwable $exception + public Throwable $exception, + public bool $sendAttempted = true, ) { } diff --git a/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php index 384f46e9d2..4c205febf8 100644 --- a/src/queue/src/FailoverQueue.php +++ b/src/queue/src/FailoverQueue.php @@ -87,8 +87,7 @@ public function reservedSize(UnitEnum|string|null $queue = null): int */ public function totalSize(): int { - // Aggregate counts are an optional driver capability outside the core Queue contract. - return $this->manager->connection($this->connections[0])->totalSize(); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->totalSize(); // @phpstan-ignore method.notFound (optional driver capability outside the Queue contract) } /** @@ -96,7 +95,7 @@ public function totalSize(): int */ public function totalPendingSize(): int { - return $this->manager->connection($this->connections[0])->totalPendingSize(); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->totalPendingSize(); // @phpstan-ignore method.notFound (optional driver capability outside the Queue contract) } /** @@ -104,7 +103,7 @@ public function totalPendingSize(): int */ public function totalDelayedSize(): int { - return $this->manager->connection($this->connections[0])->totalDelayedSize(); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->totalDelayedSize(); // @phpstan-ignore method.notFound (optional driver capability outside the Queue contract) } /** @@ -112,7 +111,7 @@ public function totalDelayedSize(): int */ public function totalReservedSize(): int { - return $this->manager->connection($this->connections[0])->totalReservedSize(); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->totalReservedSize(); // @phpstan-ignore method.notFound (optional driver capability outside the Queue contract) } /** @@ -120,8 +119,7 @@ public function totalReservedSize(): int */ public function pendingJobs(UnitEnum|string|null $queue = null): Collection { - // Inspection remains an optional concrete capability, not part of the core Queue contract. - return $this->manager->connection($this->connections[0])->pendingJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->pendingJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound (optional driver capability outside the Queue contract) } /** @@ -129,7 +127,7 @@ public function pendingJobs(UnitEnum|string|null $queue = null): Collection */ public function delayedJobs(UnitEnum|string|null $queue = null): Collection { - return $this->manager->connection($this->connections[0])->delayedJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->delayedJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound (optional driver capability outside the Queue contract) } /** @@ -137,7 +135,7 @@ public function delayedJobs(UnitEnum|string|null $queue = null): Collection */ public function reservedJobs(UnitEnum|string|null $queue = null): Collection { - return $this->manager->connection($this->connections[0])->reservedJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->reservedJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound (optional driver capability outside the Queue contract) } /** @@ -145,7 +143,7 @@ public function reservedJobs(UnitEnum|string|null $queue = null): Collection */ public function allPendingJobs(): Collection { - return $this->manager->connection($this->connections[0])->allPendingJobs(); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->allPendingJobs(); // @phpstan-ignore method.notFound (optional driver capability outside the Queue contract) } /** @@ -153,7 +151,7 @@ public function allPendingJobs(): Collection */ public function allDelayedJobs(): Collection { - return $this->manager->connection($this->connections[0])->allDelayedJobs(); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->allDelayedJobs(); // @phpstan-ignore method.notFound (optional driver capability outside the Queue contract) } /** @@ -161,7 +159,7 @@ public function allDelayedJobs(): Collection */ public function allReservedJobs(): Collection { - return $this->manager->connection($this->connections[0])->allReservedJobs(); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->allReservedJobs(); // @phpstan-ignore method.notFound (optional driver capability outside the Queue contract) } /** diff --git a/src/queue/src/InteractsWithQueue.php b/src/queue/src/InteractsWithQueue.php index 050b3c893b..fc9bdf45c3 100644 --- a/src/queue/src/InteractsWithQueue.php +++ b/src/queue/src/InteractsWithQueue.php @@ -68,6 +68,8 @@ public function release(DateInterval|DateTimeInterface|int $delay = 0): void /** * Indicate that queue interactions like fail, delete, and release should be faked. + * + * @return $this */ public function withFakeQueueInteractions(): static { @@ -78,6 +80,8 @@ public function withFakeQueueInteractions(): static /** * Assert that the job was deleted from the queue. + * + * @return $this */ public function assertDeleted(): static { @@ -93,6 +97,8 @@ public function assertDeleted(): static /** * Assert that the job was not deleted from the queue. + * + * @return $this */ public function assertNotDeleted(): static { @@ -108,6 +114,8 @@ public function assertNotDeleted(): static /** * Assert that the job was manually failed. + * + * @return $this */ public function assertFailed(): static { @@ -123,6 +131,8 @@ public function assertFailed(): static /** * Assert that the job was manually failed with a specific exception. + * + * @return $this */ public function assertFailedWith(string|Throwable $exception): static { @@ -132,7 +142,7 @@ public function assertFailedWith(string|Throwable $exception): static PHPUnit::assertInstanceOf( $exception, $this->job->failedWith, // @phpstan-ignore property.notFound (FakeJob test-double property) - 'Expected job to be manually failed with [' . $exception . '] but job failed with [' . get_class($this->job->failedWith) . '].' // @phpstan-ignore property.notFound + 'Expected job to be manually failed with [' . $exception . '] but job failed with [' . get_class($this->job->failedWith) . '].' // @phpstan-ignore property.notFound (FakeJob test-double property) ); return $this; @@ -145,19 +155,19 @@ public function assertFailedWith(string|Throwable $exception): static PHPUnit::assertInstanceOf( get_class($exception), $this->job->failedWith, // @phpstan-ignore property.notFound (FakeJob test-double property) - 'Expected job to be manually failed with [' . get_class($exception) . '] but job failed with [' . get_class($this->job->failedWith) . '].' // @phpstan-ignore property.notFound + 'Expected job to be manually failed with [' . get_class($exception) . '] but job failed with [' . get_class($this->job->failedWith) . '].' // @phpstan-ignore property.notFound (FakeJob test-double property) ); PHPUnit::assertEquals( $exception->getCode(), - $this->job->failedWith->getCode(), // @phpstan-ignore property.notFound (FakeJob test-double property) - 'Expected exception code [' . $exception->getCode() . '] but job failed with exception code [' . $this->job->failedWith->getCode() . '].' // @phpstan-ignore property.notFound + $this->job->failedWith->getCode(), + 'Expected exception code [' . $exception->getCode() . '] but job failed with exception code [' . $this->job->failedWith->getCode() . '].' ); PHPUnit::assertEquals( $exception->getMessage(), - $this->job->failedWith->getMessage(), // @phpstan-ignore property.notFound (FakeJob test-double property) - 'Expected exception message [' . $exception->getMessage() . '] but job failed with exception message [' . $this->job->failedWith->getMessage() . '].' // @phpstan-ignore property.notFound + $this->job->failedWith->getMessage(), + 'Expected exception message [' . $exception->getMessage() . '] but job failed with exception message [' . $this->job->failedWith->getMessage() . '].' ); return $this; @@ -165,6 +175,8 @@ public function assertFailedWith(string|Throwable $exception): static /** * Assert that the job was not manually failed. + * + * @return $this */ public function assertNotFailed(): static { @@ -180,6 +192,8 @@ public function assertNotFailed(): static /** * Assert that the job was released back onto the queue. + * + * @return $this */ public function assertReleased(DateInterval|DateTimeInterface|int|null $delay = null): static { @@ -208,6 +222,8 @@ public function assertReleased(DateInterval|DateTimeInterface|int|null $delay = /** * Assert that the job was not released back onto the queue. + * + * @return $this */ public function assertNotReleased(): static { @@ -235,6 +251,8 @@ private function ensureQueueInteractionsHaveBeenFaked(): void /** * Set the base queue job instance. + * + * @return $this */ public function setJob(JobContract $job): static { diff --git a/src/queue/src/Middleware/RateLimited.php b/src/queue/src/Middleware/RateLimited.php index a0c72d4740..52eba1afd2 100644 --- a/src/queue/src/Middleware/RateLimited.php +++ b/src/queue/src/Middleware/RateLimited.php @@ -105,6 +105,8 @@ protected function handleJob(mixed $job, callable $next, array $limits, Limiter /** * Set the delay (in seconds) to release the job back to the queue. + * + * @return $this */ public function releaseAfter(DateTimeInterface|int $releaseAfter): static { @@ -115,6 +117,8 @@ public function releaseAfter(DateTimeInterface|int $releaseAfter): static /** * Set the extra seconds added to the rate limiter's retry delay. + * + * @return $this */ public function releasePadding(int $seconds): static { @@ -129,6 +133,8 @@ public function releasePadding(int $seconds): static /** * Do not release the job back to the queue if the limit is exceeded. + * + * @return $this */ public function dontRelease(): static { @@ -141,6 +147,8 @@ public function dontRelease(): static // instead of exposing Laravel's Redis-only queue middleware and connection(). /** * Specify the rate limiter store that should be used. + * + * @return $this */ public function store(UnitEnum|string $store): static { diff --git a/src/queue/src/Middleware/ThrottlesExceptions.php b/src/queue/src/Middleware/ThrottlesExceptions.php index fa352698fa..7e853f8f3f 100644 --- a/src/queue/src/Middleware/ThrottlesExceptions.php +++ b/src/queue/src/Middleware/ThrottlesExceptions.php @@ -146,6 +146,8 @@ public function handle(mixed $job, callable $next): mixed /** * Specify a callback that should determine if rate limiting behavior should apply. + * + * @return $this */ public function when(callable $callback): static { @@ -156,6 +158,8 @@ public function when(callable $callback): static /** * Add a callback that should determine if the job should be deleted. + * + * @return $this */ public function deleteWhen(callable|string $callback): static { @@ -168,6 +172,8 @@ public function deleteWhen(callable|string $callback): static /** * Add a callback that should determine if the job should be failed. + * + * @return $this */ public function failWhen(callable|string $callback): static { @@ -210,6 +216,8 @@ protected function shouldFail(Throwable $throwable): bool /** * Set the prefix of the rate limiter key. + * + * @return $this */ public function withPrefix(string $prefix): static { @@ -222,6 +230,8 @@ public function withPrefix(string $prefix): static // instead of exposing Laravel's Redis-only queue middleware and connection(). /** * Specify the rate limiter store that should be used. + * + * @return $this */ public function store(UnitEnum|string $store): static { @@ -234,6 +244,7 @@ public function store(UnitEnum|string $store): static * Specify the number of minutes a job should be delayed when it is released (before it has reached its max exceptions). * * @param (Closure(Throwable): int)|int $backoff + * @return $this */ public function backoff(Closure|int $backoff): static { @@ -276,6 +287,8 @@ protected function getKey(mixed $job): string /** * Set the value that the rate limiter should be keyed by. + * + * @return $this */ public function by(string $key): static { @@ -286,6 +299,8 @@ public function by(string $key): static /** * Indicate that the throttle key should use the job's UUID. + * + * @return $this */ public function byJob(): static { @@ -296,6 +311,8 @@ public function byJob(): static /** * Report exceptions and optionally specify a callback that determines if the exception should be reported. + * + * @return $this */ public function report(?callable $callback = null): static { diff --git a/src/queue/src/Middleware/WithoutOverlapping.php b/src/queue/src/Middleware/WithoutOverlapping.php index c159ffe3af..78de0e4107 100644 --- a/src/queue/src/Middleware/WithoutOverlapping.php +++ b/src/queue/src/Middleware/WithoutOverlapping.php @@ -85,6 +85,8 @@ public function handle(mixed $job, callable $next): mixed /** * Set the delay (in seconds) to release the job back to the queue. + * + * @return $this */ public function releaseAfter(DateTimeInterface|int $releaseAfter): static { @@ -95,6 +97,8 @@ public function releaseAfter(DateTimeInterface|int $releaseAfter): static /** * Do not release the job back to the queue if no lock can be acquired. + * + * @return $this */ public function dontRelease(): static { @@ -105,6 +109,8 @@ public function dontRelease(): static /** * Set the maximum number of seconds that can elapse before the lock is released. + * + * @return $this */ public function expireAfter(DateInterval|DateTimeInterface|int $expiresAfter): static { @@ -115,6 +121,8 @@ public function expireAfter(DateInterval|DateTimeInterface|int $expiresAfter): s /** * Set the prefix of the lock key. + * + * @return $this */ public function withPrefix(string $prefix): static { @@ -125,6 +133,8 @@ public function withPrefix(string $prefix): static /** * Indicate that the lock key may be shared across jobs belonging to different classes. + * + * @return $this */ public function shared(): static { @@ -135,6 +145,8 @@ public function shared(): static /** * Specify the cache store that should hold the lock. + * + * @return $this */ public function store(UnitEnum|string $store): static { diff --git a/src/queue/src/Outbox/OutboxWorker.php b/src/queue/src/Outbox/OutboxWorker.php index 37e105981d..02246c6675 100644 --- a/src/queue/src/Outbox/OutboxWorker.php +++ b/src/queue/src/Outbox/OutboxWorker.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\Exceptions\ChildCancellationException; use Hypervel\Coroutine\SignalRegistry; use Hypervel\Coroutine\WaitConcurrent; @@ -119,9 +120,11 @@ public function run(?string $database, OutboxWorkerOptions $options): int if (! $this->relay->deliver($source, $messages)) { Sleep::usleep((int) ($options->sleep * 1_000_000)); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $taskFailure ??= $exception; } }); @@ -141,16 +144,14 @@ public function run(?string $database, OutboxWorkerOptions $options): int } catch (Throwable $exception) { $failure = $exception; - if ($exception instanceof CanceledException) { + if (Coroutine::causedByCancellation($exception)) { $concurrent->cancel(); } } finally { try { $this->join($concurrent); } catch (CanceledException $exception) { - if (! $failure instanceof CanceledException) { - $failure = $exception; - } + $failure = Coroutine::preferCancellation($failure, $exception); } finally { $this->signals->unregister($signalHandler); } @@ -172,9 +173,13 @@ protected function claim(string $database, int $limit): ?array { try { return $this->relay->claim($database, $limit); - } catch (CanceledException|ChildCancellationException $exception) { + } catch (ChildCancellationException $exception) { throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + (new Waiter(-1))->wait( fn () => $this->exceptions->report($exception, ['outbox_database' => $database]), waitForChildTermination: true, diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 44a1db8309..1685ca46ee 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -32,15 +32,16 @@ use Hypervel\Queue\Events\JobPayloadFinalizing; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; +use Hypervel\Queue\Events\JobQueueingCanceled; use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Context; +use Hypervel\Support\FailureReporter; use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; use Hypervel\Support\Str; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; @@ -94,7 +95,7 @@ abstract class Queue */ public function pushOn(UnitEnum|string|null $queue, object|string $job, mixed $data = ''): mixed { - return $this->push($job, $data, $queue); // @phpstan-ignore method.notFound + return $this->push($job, $data, $queue); // @phpstan-ignore method.notFound (declared by the Queue contract each driver implements) } /** @@ -102,7 +103,7 @@ public function pushOn(UnitEnum|string|null $queue, object|string $job, mixed $d */ public function laterOn(UnitEnum|string|null $queue, DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data = ''): mixed { - return $this->later($delay, $job, $data, $queue); // @phpstan-ignore method.notFound + return $this->later($delay, $job, $data, $queue); // @phpstan-ignore method.notFound (declared by the Queue contract each driver implements) } /** @@ -117,9 +118,9 @@ public function bulk(array $jobs, mixed $data = '', UnitEnum|string|null $queue $delay = $this->getJobDelay($job); if ($delay !== null) { - $this->later($delay, $job, $data, $queue); // @phpstan-ignore method.notFound + $this->later($delay, $job, $data, $queue); // @phpstan-ignore method.notFound (declared by the Queue contract each driver implements) } else { - $this->push($job, $data, $queue); // @phpstan-ignore method.notFound + $this->push($job, $data, $queue); // @phpstan-ignore method.notFound (declared by the Queue contract each driver implements) } } @@ -280,9 +281,11 @@ function () use ($uniqueJobMetadata, $queue, $payload): array { $command = $this->jobShouldBeEncrypted($job) && $this->container->has(Encrypter::class) ? $this->container->make(Encrypter::class)->encrypt(serialize(clone $job)) : serialize(clone $job); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + throw new RuntimeException( sprintf('Failed to serialize job of type [%s]: %s', get_class($job), $e->getMessage()), 0, @@ -425,6 +428,7 @@ protected function withCreatePayloadHooks(?string $queue, array $payload): array * Set the dispatcher for after-commit queue operations. * * @param null|Closure(Closure(Queue): mixed): mixed $dispatcher + * @return $this */ public function setAfterCommitDispatcher(?Closure $dispatcher): static { @@ -446,11 +450,7 @@ protected function enqueueUsing(object|string $job, string $payload, ?string $qu try { $stageViaOutbox = $this->shouldStageViaOutbox($job); } catch (Throwable $exception) { - if (Coroutine::causedByCancellation($exception)) { - throw $exception; - } - - $this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception); + $this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception, sendAttempted: false); throw $exception; } @@ -580,17 +580,16 @@ protected static function runDeferredEnqueue(array $jobs, Closure $operation): m */ protected function enqueueNow(object|string $job, string $payload, ?string $queue, DateInterval|DateTimeInterface|int|null $delay, callable $callback): mixed { + $sendAttempted = false; + try { $payload = $this->finalizePayloadForQueueing($queue, $job, $payload, $delay); $this->raiseJobQueueingEvent($queue, $job, $payload, $delay); $storedPayload = $this->preparePayloadForPush($job, $payload, $queue); + $sendAttempted = true; $jobId = $callback($this, $storedPayload, $queue, $delay); } catch (Throwable $exception) { - if (Coroutine::causedByCancellation($exception)) { - throw $exception; - } - - $this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception); + $this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception, $sendAttempted); throw $exception; } @@ -598,24 +597,17 @@ protected function enqueueNow(object|string $job, string $payload, ?string $queu $this->acceptDispatchLocks($job); $exception = null; + // The job is stored, so a canceled post-storage step must not skip its queued notification. try { $this->handlePayloadPushed($storedPayload, $queue); } catch (Throwable $throwable) { - if (Coroutine::causedByCancellation($throwable)) { - throw $throwable; - } - $exception = $throwable; } try { $this->raiseJobQueuedEvent($queue, $jobId, $job, $payload, $delay); } catch (Throwable $throwable) { - if (Coroutine::causedByCancellation($throwable)) { - throw $throwable; - } - - $exception ??= $throwable; + $exception = FailureReporter::preferCancellation($exception, $throwable); } if ($exception !== null) { @@ -643,13 +635,20 @@ protected function handlePayloadPushed(string $payload, ?string $queue): void /** * Notify every batch member and return the first ordinary notification failure. * + * A canceled notification does not skip the remaining members; the first + * cancellation is rethrown once every member has been notified, and the + * ordinary failure it displaces is reported. + * * @template TMember * @param array $members * @param Closure(TMember): void ...$notifications + * + * @throws Throwable the first notification cancellation */ protected function notifyBatchMembers(array $members, Closure ...$notifications): ?Throwable { $exception = null; + $cancellation = null; foreach ($members as $member) { foreach ($notifications as $notification) { @@ -657,19 +656,27 @@ protected function notifyBatchMembers(array $members, Closure ...$notifications) $notification($member); } catch (Throwable $throwable) { if (Coroutine::causedByCancellation($throwable)) { - throw $throwable; + $cancellation = Coroutine::preferCancellation($cancellation, $throwable); + } else { + $exception ??= $throwable; } - - $exception ??= $throwable; } } } + if ($cancellation !== null) { + if ($exception !== null) { + FailureReporter::report($exception); + } + + throw $cancellation; + } + return $exception; } /** - * Notify every batch member of a failure, reporting listener errors so they cannot replace it. + * Notify every batch member of a failure or cancellation, reporting listener errors so they cannot replace it. * * @template TMember * @param array $members @@ -842,14 +849,21 @@ protected function raiseJobQueueingEvent(?string $queue, object|string $job, str } /** - * Raise the job queueing failed event. + * Raise the job queueing failed event, or the canceled event when cancellation stopped the job. * * @param Closure|object|string $job */ - protected function raiseJobQueueingFailedEvent(?string $queue, object|string $job, string $payload, DateInterval|DateTimeInterface|int|null $delay, Throwable $exception): void - { - // Cancellation is not a queueing failure; wrapped cancellation skips the event like a bare one. + protected function raiseJobQueueingFailedEvent( + ?string $queue, + object|string $job, + string $payload, + DateInterval|DateTimeInterface|int|null $delay, + Throwable $exception, + bool $sendAttempted = true, + ): void { if (Coroutine::causedByCancellation($exception)) { + $this->raiseJobQueueingCanceledEvent($queue, $job, $payload, $delay, $exception, $sendAttempted); + return; } @@ -863,7 +877,61 @@ protected function raiseJobQueueingFailedEvent(?string $queue, object|string $jo $delay = ! is_null($delay) ? $this->secondsUntil($delay) : $delay; - $events->dispatch(new JobQueueingFailed($this->connectionName, $queue, $job, $payload, $delay, $exception)); + try { + $events->dispatch(new JobQueueingFailed($this->connectionName, $queue, $job, $payload, $delay, $exception, $sendAttempted)); + } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->raiseJobQueueingCanceledEvent($queue, $job, $payload, $delay, $throwable, $sendAttempted, exception: $exception); + } + + throw $throwable; + } + } + } + + /** + * Raise the job queueing canceled event. + * + * The cancellation is already propagating, so a listener failure is + * reported instead of replacing it. + * + * @param Closure|object|string $job + */ + protected function raiseJobQueueingCanceledEvent( + ?string $queue, + object|string $job, + string $payload, + DateInterval|DateTimeInterface|int|null $delay, + Throwable $cancellation, + bool $sendAttempted, + bool $queued = false, + ?Throwable $exception = null, + ): void { + if ($this->container->bound('events')) { + /** @var EventDispatcher $events */ + $events = $this->container->make('events'); + + if (! $events->hasListeners(JobQueueingCanceled::class)) { + return; + } + + $delay = ! is_null($delay) ? $this->secondsUntil($delay) : $delay; + + try { + $events->dispatch(new JobQueueingCanceled( + $this->connectionName, + $queue, + $job, + $payload, + $delay, + $cancellation, + $sendAttempted, + $queued, + $exception, + )); + } catch (Throwable $throwable) { + FailureReporter::report($throwable); + } } } @@ -884,7 +952,15 @@ protected function raiseJobQueuedEvent(?string $queue, mixed $jobId, object|stri $delay = ! is_null($delay) ? $this->secondsUntil($delay) : $delay; - $events->dispatch(new JobQueued($this->connectionName, $queue, $jobId, $job, $payload, $delay)); + try { + $events->dispatch(new JobQueued($this->connectionName, $queue, $jobId, $job, $payload, $delay)); + } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->raiseJobQueueingCanceledEvent($queue, $job, $payload, $delay, $throwable, sendAttempted: true, queued: true); + } + + throw $throwable; + } } } @@ -926,6 +1002,8 @@ public function getConnectionName(): string * Boot or tests only. Queue connection instances are cached on * QueueManager; runtime use races across coroutines and changes every * concurrent dispatch through this connection. + * + * @return $this */ public function setConnectionName(string $name): static { @@ -948,6 +1026,8 @@ public function getConfig(): array * Boot or tests only. Queue connection instances are cached on * QueueManager; runtime use races across coroutines and changes every * concurrent dispatch through this connection. + * + * @return $this */ public function setConfig(array $config): static { diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 4ec0b78446..be4e89d2dc 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -186,7 +186,8 @@ public function whenJobRunsLongerThan(DateTimeInterface|CarbonInterval|float|int $this->app->make('events') ->listen(JobAttempted::class, function (JobAttempted $event) use ($threshold, $handler): void { - if (! is_null($event->duration) && $event->duration > $threshold) { + // Application callbacks do not run in a canceled or killed attempt. + if (! $event->canceled && ! is_null($event->duration) && $event->duration > $threshold) { $handler($event->job, $event->duration); } }); @@ -360,12 +361,13 @@ public function getPausedQueues(array $queues, ?string $connection = null): arra return array_values($queues); } + // A list keeps keyed input from being read as key/default pairs. $keys = array_map( static fn (string $queue): string => "illuminate:queue:paused:{$connection}:{$queue}", - $queues, + array_values($queues), ); - $states = $cache->many($keys); + $states = $cache->get($keys); return array_values(array_filter( $queues, @@ -640,6 +642,8 @@ public function getApplication(): Container * Tests only. Swaps the container reference on the singleton and on every * cached queue connection; per-request use races across coroutines and * breaks every concurrent dispatch. + * + * @return $this */ public function setApplication(Container $app): static { diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php index 09f3b02fca..37155b159b 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -403,6 +403,8 @@ protected function prepareBatchJobs(array $jobs, mixed $data, ?string $queue): a */ protected function enqueueBatch(array $jobs, ?string $queue): void { + $sendAttempted = false; + try { foreach ($jobs as $index => $job) { $jobs[$index]['payload'] = $this->finalizePayloadForQueueing( @@ -432,15 +434,12 @@ protected function enqueueBatch(array $jobs, ?string $queue): void $connection = $this->getConnection(); $this->isCluster ??= $connection->isCluster(); $queueKey = $this->getQueueRedisKey($queue); + $sendAttempted = true; $this->pushRawBatch($connection, $queueKey, $arguments); } catch (Throwable $exception) { - if (Coroutine::causedByCancellation($exception)) { - throw $exception; - } - $this->notifyBatchMembersOfFailure( $jobs, - fn (array $job) => $this->raiseJobQueueingFailedEvent($queue, $job['job'], $job['payload'], $job['delay'], $exception), + fn (array $job) => $this->raiseJobQueueingFailedEvent($queue, $job['job'], $job['payload'], $job['delay'], $exception, $sendAttempted), ); throw $exception; diff --git a/src/queue/src/SerializesModels.php b/src/queue/src/SerializesModels.php index bc3478d20e..31d282f436 100644 --- a/src/queue/src/SerializesModels.php +++ b/src/queue/src/SerializesModels.php @@ -19,9 +19,10 @@ public function __serialize(): array { $values = []; - [$class, $properties, $classLevelWithoutRelations] = [ - get_class($this), - ClassMetadataCache::properties($this), + // Unlike Laravel, a parent's private properties are serialized too, keyed by their declaring + // class as PHP keys them, so same-named private properties at different levels stay separate. + [$properties, $classLevelWithoutRelations] = [ + ClassMetadataCache::allProperties($this), ClassMetadataCache::hasClassAttribute($this, WithoutRelations::class, ascend: true), ]; @@ -47,7 +48,7 @@ public function __serialize(): array $name = $property->getName(); if ($property->isPrivate()) { - $name = "\0{$class}\0{$name}"; + $name = "\0{$property->class}\0{$name}"; } elseif ($property->isProtected()) { $name = "\0*\0{$name}"; } @@ -67,9 +68,7 @@ public function __serialize(): array */ public function __unserialize(array $values): void { - $properties = ClassMetadataCache::properties($this); - - $class = get_class($this); + $properties = ClassMetadataCache::allProperties($this); foreach ($properties as $property) { if ($property->isStatic()) { @@ -79,7 +78,7 @@ public function __unserialize(array $values): void $name = $property->getName(); if ($property->isPrivate()) { - $name = "\0{$class}\0{$name}"; + $name = "\0{$property->class}\0{$name}"; } elseif ($property->isProtected()) { $name = "\0*\0{$name}"; } @@ -88,7 +87,9 @@ public function __unserialize(array $values): void continue; } - $property->setValue( + // Unlike Laravel, the stored value is restored without running a set hook, as native + // unserialization does, since the hook already ran when the value was stored. + $property->setRawValue( $this, $this->getRestoredPropertyValue($values[$name]) ); @@ -97,9 +98,12 @@ public function __unserialize(array $values): void /** * Get the property value for the given property. + * + * Unlike Laravel, this reads the stored value without running a get hook, as native + * serialization does. */ protected function getPropertyValue(ReflectionProperty $property): mixed { - return $property->getValue($this); + return $property->getRawValue($this); } } diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php index 87f36ef735..b70655efc6 100644 --- a/src/queue/src/SqsQueue.php +++ b/src/queue/src/SqsQueue.php @@ -24,6 +24,7 @@ use Hypervel\Queue\Outbox\OutboxMessage; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Support\FailureReporter; use Hypervel\Support\Str; use LogicException; use RuntimeException; @@ -423,6 +424,7 @@ protected function prepareBatchMessages(array $jobs, mixed $data, ?string $queue protected function sendBatchedMessages(array $messages, ?string $queue): void { $outstandingMessageIds = array_fill_keys(array_keys($messages), true); + $attemptedMessageIds = []; try { foreach ($messages as $id => $message) { @@ -457,11 +459,7 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void $store = $overflow === [] ? null : $this->overflowStore(); $chunks = $this->chunkBatchEntries($entries); } catch (Throwable $exception) { - if (Coroutine::causedByCancellation($exception)) { - throw $exception; - } - - $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception); + $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $attemptedMessageIds, $queue, $exception); throw $exception; } @@ -475,15 +473,14 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void $this->raiseJobQueueingEvent($queue, $message['job'], $message['payload'], $message['delay']); } + // Overflow storage is part of the send, as in pushRaw(). + $attemptedMessageIds += array_fill_keys(array_column($chunk, 'Id'), true); + if ($store !== null) { $this->storeBatchOverflowPayloads($chunk, $overflow, $store); } } catch (Throwable $exception) { - if (Coroutine::causedByCancellation($exception)) { - throw $exception; - } - - $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception); + $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $attemptedMessageIds, $queue, $exception); throw $exception; } @@ -496,11 +493,7 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void 'Entries' => $chunk, ]); } catch (Throwable $exception) { - if (Coroutine::causedByCancellation($exception)) { - throw $exception; - } - - $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception); + $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $attemptedMessageIds, $queue, $exception); throw $exception; } @@ -518,7 +511,11 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void $accepted[] = $success; } + // Cleanup and notification throw only cancellation, which must not skip + // the accepted messages' queued events or the outstanding messages' outcomes. $exception = null; + $notificationFailure = null; + $cancellation = null; if (! empty($result['Failed'])) { $exception = $this->batchRejectionException($queueUrl, $chunk, $result); @@ -532,32 +529,50 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void } } - $this->cleanupOverflowPayloads($store, $rejectedPaths); + try { + $this->cleanupOverflowPayloads($store, $rejectedPaths); + } catch (Throwable $throwable) { + $cancellation = $throwable; + } } } - $notificationFailure = $this->notifyBatchMembers( - $accepted, - fn (array $success) => $this->handlePayloadPushed($messages[$success['Id']]['storedPayload'], $queue), - function (array $success) use ($messages, $queue): void { - $message = $messages[$success['Id']]; - $this->raiseJobQueuedEvent($queue, $success['MessageId'], $message['job'], $message['payload'], $message['delay']); - }, - ); - - if ($exception !== null && $notificationFailure !== null) { - $this->reportSecondaryFailure($notificationFailure); + try { + $notificationFailure = $this->notifyBatchMembers( + $accepted, + fn (array $success) => $this->handlePayloadPushed($messages[$success['Id']]['storedPayload'], $queue), + function (array $success) use ($messages, $queue): void { + $message = $messages[$success['Id']]; + $this->raiseJobQueuedEvent($queue, $success['MessageId'], $message['job'], $message['payload'], $message['delay']); + }, + ); + } catch (Throwable $throwable) { + $cancellation = Coroutine::preferCancellation($cancellation, $throwable); } - $exception ??= $notificationFailure; + $failure = $exception ?? $notificationFailure ?? $cancellation; - if ($exception === null) { + if ($failure === null) { continue; } - $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception); + // A rejection stays the outcome of the messages it left unsent. A canceled + // notification or report must not hide the failures still to be reported. + try { + $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $attemptedMessageIds, $queue, $failure); + } catch (Throwable $throwable) { + $cancellation = Coroutine::preferCancellation($cancellation, $throwable); + } - throw $exception; + if ($exception !== null && $notificationFailure !== null) { + try { + $this->reportSecondaryFailure($notificationFailure); + } catch (Throwable $throwable) { + $cancellation = Coroutine::preferCancellation($cancellation, $throwable); + } + } + + throw $cancellation === null ? $failure : FailureReporter::preferCancellation($failure, $cancellation); } } @@ -692,19 +707,30 @@ protected function isSenderFaultRejection(array $failure): bool } /** - * Raise queueing-failed events for every outstanding message. + * Raise queueing-failed or canceled events for every outstanding message. + * + * @param array $outstandingMessageIds + * @param array $attemptedMessageIds */ protected function raiseBatchFailedEvents( array $messages, array $outstandingMessageIds, + array $attemptedMessageIds, ?string $queue, Throwable $exception, ): void { $this->notifyBatchMembersOfFailure( array_keys($outstandingMessageIds), - function (int $id) use ($messages, $queue, $exception): void { + function (int $id) use ($messages, $attemptedMessageIds, $queue, $exception): void { $message = $messages[$id]; - $this->raiseJobQueueingFailedEvent($queue, $message['job'], $message['payload'], $message['delay'], $exception); + $this->raiseJobQueueingFailedEvent( + $queue, + $message['job'], + $message['payload'], + $message['delay'], + $exception, + isset($attemptedMessageIds[$id]), + ); }, ); } @@ -854,7 +880,7 @@ protected function cleanupOverflowPayloads(CacheRepository $store, array $paths) try { $this->reportSecondaryFailure($exception); } catch (Throwable $reportingCancellation) { - $cancellation ??= $reportingCancellation; + $cancellation = Coroutine::preferCancellation($cancellation, $reportingCancellation); } } } diff --git a/src/queue/src/SyncQueue.php b/src/queue/src/SyncQueue.php index a38a118aaa..9f8c205e9c 100644 --- a/src/queue/src/SyncQueue.php +++ b/src/queue/src/SyncQueue.php @@ -19,6 +19,7 @@ use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\Jobs\SyncJob; use Hypervel\Support\Collection; +use Hypervel\Support\FailureReporter; use Throwable; use UnitEnum; @@ -235,7 +236,7 @@ protected function executePayload(string $payload, ?string $queue = null): int protected function processPayload(string $payload, ?string $queue = null): int { $queueJob = $this->resolveJob($payload, $queue); - $canceled = false; + $cancellation = null; try { $this->raiseBeforeJobEvent($queueJob); @@ -251,7 +252,7 @@ protected function processPayload(string $payload, ?string $queue = null): int $this->raiseAfterJobEvent($queueJob, $duration); } catch (Throwable $e) { if (Coroutine::causedByCancellation($e)) { - $canceled = true; + $cancellation = $e; throw $e; } @@ -261,13 +262,22 @@ protected function processPayload(string $payload, ?string $queue = null): int try { $this->handleException($queueJob, $e); } catch (Throwable $exception) { - $canceled = Coroutine::causedByCancellation($exception); + if (Coroutine::causedByCancellation($exception)) { + $cancellation = $exception; + } throw $exception; } } finally { - if (! $canceled) { + if ($cancellation === null) { $this->raiseJobAttemptedEvent($queueJob, $exceptionOccurred ?? null, $duration ?? null); + } else { + // Telemetry that JobProcessing began must end; the unwinding cancellation keeps precedence. + try { + $this->raiseJobAttemptedEvent($queueJob, $exceptionOccurred ?? $cancellation, $duration ?? null, canceled: true); + } catch (Throwable $exception) { + FailureReporter::report($exception); + } } } @@ -317,14 +327,14 @@ protected function raiseAfterJobEvent(JobContract $job, ?float $duration = null) /** * Raise the job attempted event. */ - protected function raiseJobAttemptedEvent(JobContract $job, ?Throwable $exceptionOccurred = null, ?float $duration = null): void + protected function raiseJobAttemptedEvent(JobContract $job, ?Throwable $exceptionOccurred = null, ?float $duration = null, bool $canceled = false): void { if ($this->container->bound('events')) { /** @var EventDispatcher $events */ $events = $this->container->make('events'); if ($events->hasListeners(JobAttempted::class)) { - $events->dispatch(new JobAttempted($this->connectionName, $job, $exceptionOccurred, $duration)); + $events->dispatch(new JobAttempted($this->connectionName, $job, $exceptionOccurred, $duration, $canceled)); } } } diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 088bc0e82f..0017154d69 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -44,6 +44,7 @@ use Hypervel\Queue\Events\WorkerStopping; use Hypervel\Queue\QueueManager as ConcreteQueueManager; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\FailureReporter; use Hypervel\Support\Sleep; use Hypervel\Support\Str; use LogicException; @@ -451,6 +452,7 @@ function () use ($connectionName, $queue, $options): ?JobContract { throw $exception; } + // @phpstan-ignore hypervel.cancellation.exactType (a direct cancellation ends the job quietly; a failure carrying it is reported) if (! $exception instanceof CanceledException && static::$reportJobExceptions) { $this->withCoroutineContext($options, fn () => $this->reportException($exception)); } @@ -1200,6 +1202,8 @@ public function process(string $connectionName, JobContract $job, WorkerOptions $runningJobId = null; $invalidPayloadException = null; $canceled = false; + // The throwable unwinding a canceled attempt; null when the timeout monitor took over a body that returned. + $cancellation = null; $processingMarker = null; try { @@ -1256,6 +1260,7 @@ public function process(string $connectionName, JobContract $job, WorkerOptions $notification->wait(); } catch (CanceledException $exception) { $canceled = true; + $cancellation = $exception; throw $exception; } @@ -1292,15 +1297,18 @@ public function process(string $connectionName, JobContract $job, WorkerOptions if ($runningJobId !== null && isset($this->timeoutJobIds[$runningJobId])) { if (static::$killOnTimeout) { $canceled = true; + $cancellation = $e; throw $e; } + // @phpstan-ignore hypervel.cancellation.exactType (the timeout's own cancellation is expected; a failure carrying it is reported) if (! $e instanceof CanceledException && ! $e instanceof TimeoutExceededException && static::$reportJobExceptions) { try { $this->reportException($e); } catch (Throwable $exception) { $canceled = true; + $cancellation = $exception; throw $exception; } @@ -1309,6 +1317,7 @@ public function process(string $connectionName, JobContract $job, WorkerOptions $e = $this->timeoutExceededException($job); } elseif (FrameworkCoroutine::causedByCancellation($e)) { $canceled = true; + $cancellation = $e; throw $e; } @@ -1318,7 +1327,10 @@ public function process(string $connectionName, JobContract $job, WorkerOptions try { $this->handleJobException($connectionName, $job, $options, $e); } catch (Throwable $exception) { - $canceled = FrameworkCoroutine::causedByCancellation($exception); + if (FrameworkCoroutine::causedByCancellation($exception)) { + $canceled = true; + $cancellation = $exception; + } throw $exception; } @@ -1352,6 +1364,25 @@ public function process(string $connectionName, JobContract $job, WorkerOptions if ($invalidPayloadException !== null && static::$reportJobExceptions) { $this->reportException($invalidPayloadException); } + } elseif ($this->events->hasListeners(JobAttempted::class)) { + // Telemetry that JobProcessing began must end in this coroutine. The monitor + // or the unwinding cancellation owns the attempt's other completion steps. + try { + $this->events->dispatch(new JobAttempted( + $connectionName, + $job, + $exceptionOccurred ?? ($timedOut ? $this->timeoutExceededException($job) : $cancellation), + $duration ?? null, + canceled: true, + )); + } catch (Throwable $exception) { + // Nothing else is unwinding once the monitor took over a body that returned. + if ($cancellation === null) { + throw $exception; + } + + FailureReporter::report($exception); + } } } } @@ -1987,6 +2018,8 @@ public function sleep(float|int $seconds): void * * Boot-only. The repository persists on the singleton worker for the * worker lifetime and is read by every subsequent pause or restart check. + * + * @return $this */ public function setCache(CacheContract $cache): static { @@ -2000,6 +2033,8 @@ public function setCache(CacheContract $cache): static * * Boot-only. The name persists on the singleton worker for the worker * lifetime and selects the static pop callback used by every job pop. + * + * @return $this */ public function setName(string $name): static { diff --git a/src/rate-limiter/src/RateLimiter.php b/src/rate-limiter/src/RateLimiter.php index 9e0ee4304e..c4abc7e4c3 100644 --- a/src/rate-limiter/src/RateLimiter.php +++ b/src/rate-limiter/src/RateLimiter.php @@ -65,6 +65,8 @@ public function store(UnitEnum|string|null $name = null): Limiter * * Boot-only. The callback and store selection persist on the singleton * manager for the worker lifetime and affect every subsequent request. + * + * @return $this */ public function for( UnitEnum|string $name, diff --git a/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php b/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php index 5ed9d89b31..67f2ab83f7 100644 --- a/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php +++ b/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php @@ -50,6 +50,8 @@ public function __construct( /** * Set the maximum number of locks that can be obtained per time window. + * + * @return $this */ public function limit(int $maxLocks): static { @@ -60,6 +62,8 @@ public function limit(int $maxLocks): static /** * Set the number of seconds until the lock will be released. + * + * @return $this */ public function releaseAfter(DateTimeInterface|DateInterval|int $releaseAfter): static { @@ -70,6 +74,8 @@ public function releaseAfter(DateTimeInterface|DateInterval|int $releaseAfter): /** * Set the amount of time to block until a lock is available. + * + * @return $this */ public function block(int $timeout): static { @@ -80,6 +86,8 @@ public function block(int $timeout): static /** * Set the number of milliseconds to wait between lock acquisition attempts. + * + * @return $this */ public function sleep(int $sleep): static { diff --git a/src/redis/src/Limiters/DurationLimiterBuilder.php b/src/redis/src/Limiters/DurationLimiterBuilder.php index ff3e0872aa..86da359172 100644 --- a/src/redis/src/Limiters/DurationLimiterBuilder.php +++ b/src/redis/src/Limiters/DurationLimiterBuilder.php @@ -48,6 +48,8 @@ public function __construct( /** * Set the maximum number of locks that can be obtained per time window. + * + * @return $this */ public function allow(int $maxLocks): static { @@ -58,6 +60,8 @@ public function allow(int $maxLocks): static /** * Set the amount of time the lock window is maintained. + * + * @return $this */ public function every(DateTimeInterface|DateInterval|int $decay): static { @@ -68,6 +72,8 @@ public function every(DateTimeInterface|DateInterval|int $decay): static /** * Set the amount of time to block until a lock is available. + * + * @return $this */ public function block(int $timeout): static { @@ -78,6 +84,8 @@ public function block(int $timeout): static /** * Set the number of milliseconds to wait between lock acquisition attempts. + * + * @return $this */ public function sleep(int $sleep): static { diff --git a/src/redis/src/Listeners/RedisConnectionLifecycleListener.php b/src/redis/src/Listeners/RedisConnectionLifecycleListener.php index 5f423bd76a..12de8bb2e2 100644 --- a/src/redis/src/Listeners/RedisConnectionLifecycleListener.php +++ b/src/redis/src/Listeners/RedisConnectionLifecycleListener.php @@ -6,9 +6,9 @@ use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Redis\Factory as RedisFactory; +use Hypervel\Coroutine\Coroutine; use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\RedisManager; -use Swoole\Coroutine\CanceledException; use Throwable; class RedisConnectionLifecycleListener @@ -62,9 +62,7 @@ public function discardProcessConnections(): void try { $this->container->make(PoolManager::class)->purgeAll(); } catch (Throwable $throwable) { - if ($exception === null || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/redis/src/PhpRedisClusterConnection.php b/src/redis/src/PhpRedisClusterConnection.php index e5f3c6a400..da9a066c1b 100644 --- a/src/redis/src/PhpRedisClusterConnection.php +++ b/src/redis/src/PhpRedisClusterConnection.php @@ -280,7 +280,7 @@ protected function callFlushdb(mixed ...$arguments): mixed if ($async) { $result = $this->connection->rawCommand($master, 'flushdb', 'async'); } else { - $result = $this->connection->flushdb($master); // @phpstan-ignore argument.type (connection is always RedisCluster here) + $result = $this->connection->flushdb($master); } $successful = $result !== false && $successful; diff --git a/src/redis/src/Pool/PoolManager.php b/src/redis/src/Pool/PoolManager.php index ce582e9708..7e4f9ced9c 100644 --- a/src/redis/src/Pool/PoolManager.php +++ b/src/redis/src/Pool/PoolManager.php @@ -5,7 +5,7 @@ namespace Hypervel\Redis\Pool; use Hypervel\Contracts\Container\Container as ContainerContract; -use Swoole\Coroutine\CanceledException; +use Hypervel\Coroutine\Coroutine; use Throwable; class PoolManager @@ -33,25 +33,18 @@ public function purgeAll(): void { $pools = $this->pools; $this->pools = []; - $firstException = null; - $firstCancellation = null; + $exception = null; foreach ($pools as $pool) { try { $pool->close(); - } catch (CanceledException $exception) { - $firstCancellation ??= $exception; - } catch (Throwable $exception) { - $firstException ??= $exception; + } catch (Throwable $throwable) { + $exception = Coroutine::preferCancellation($exception, $throwable); } } - if ($firstCancellation !== null) { - throw $firstCancellation; - } - - if ($firstException !== null) { - throw $firstException; + if ($exception !== null) { + throw $exception; } } @@ -92,12 +85,8 @@ public function pool(string $name): RedisPool } catch (Throwable $failure) { try { $pool->close(); - } catch (CanceledException $cancellation) { - if (! $failure instanceof CanceledException) { - throw $cancellation; - } - } catch (Throwable) { - // Preserve the activation failure over an ordinary cleanup failure. + } catch (Throwable $cleanupFailure) { + $failure = Coroutine::preferCancellation($failure, $cleanupFailure); } throw $failure; diff --git a/src/redis/src/Pool/RedisPool.php b/src/redis/src/Pool/RedisPool.php index 71a89eb0e0..fe4f3303db 100644 --- a/src/redis/src/Pool/RedisPool.php +++ b/src/redis/src/Pool/RedisPool.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\ConnectionPool\UsageTracker; use Hypervel\Contracts\Container\Container; use Hypervel\Coordinator\Timer; +use Hypervel\Coroutine\Coroutine; use Hypervel\Redis\PhpRedisClusterConnection; use Hypervel\Redis\PhpRedisConnection; use Hypervel\Redis\RedisConfig; @@ -184,16 +185,18 @@ protected function heartbeatConnection(RedisConnection $connection): void || ($connection->isIdleExpired($now) && $this->getManagedCount() > $this->options->minRetainedConnections); $healthy = ! $expired && $connection->heartbeatCheck($this->options->heartbeatTimeout); - } catch (CanceledException $cancellation) { - try { - $this->discardHeartbeatConnection($connection); - } catch (CanceledException) { - } catch (Throwable $exception) { - $this->report($exception); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + try { + $this->discardHeartbeatConnection($connection); + } catch (CanceledException) { + } catch (Throwable $cleanupException) { + $this->report($cleanupException); + } + + throw $exception; } - throw $cancellation; - } catch (Throwable $exception) { $this->report('Redis heartbeat failed: ' . $exception); $healthy = false; } diff --git a/src/redis/src/RedisCancellation.php b/src/redis/src/RedisCancellation.php index 20228a56d6..c45b5eef2b 100644 --- a/src/redis/src/RedisCancellation.php +++ b/src/redis/src/RedisCancellation.php @@ -23,6 +23,7 @@ class RedisCancellation */ public static function cancellationFrom(Throwable $exception, string $message): ?CanceledException { + // @phpstan-ignore hypervel.cancellation.exactType (the classification primitive for phpredis failures) if ($exception instanceof CanceledException) { return $exception; } diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 2a26d6a68d..4d730b9ff3 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -14,6 +14,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Log\StdoutLoggerInterface; +use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\Waiter; use Hypervel\Redis\Exceptions\InvalidRedisOptionException; use Hypervel\Redis\Exceptions\LuaScriptException; @@ -36,7 +37,6 @@ * @method mixed set(string $key, mixed $value, mixed $expireResolution = null, int|null $expireTTL = null, string|null $flag = null) Set the value of a key * @method array|false|Redis|RedisCluster mget(array $keys) Get the values of multiple keys * @method bool|int|Redis|RedisCluster setnx(string $key, mixed $value) Set key if not exists - * @method bool|int|Redis|RedisCluster setNx(string $key, mixed $value) Set key if not exists * @method array|false|Redis|RedisCluster hmget(string $key, array $fields) Get hash field values * @method bool|Redis|RedisCluster hmset(string $key, array $fieldValues) Set hash field values * @method bool|int|Redis|RedisCluster hsetnx(string $hash, string $key, mixed $value) Set hash field if not exists @@ -59,7 +59,7 @@ * @method mixed evalsha(string $script, int $numkeys, mixed ...$arguments) Evaluate Lua script by SHA1 * @method mixed flushdb(mixed ...$arguments) Flush database * @method mixed executeRaw(array $parameters) Execute raw Redis command - * @method mixed pipeline(callable|null $callback = null) Execute commands in a pipeline + * @method bool|Redis pipeline() Execute commands in a pipeline * @method array|false|Redis|RedisCluster smembers(string $key) Get all set members * @method false|int|Redis|RedisCluster hdel(string $key, string $field, string ...$other_fields) Delete hash fields * @method false|int|Redis|RedisCluster zrem(mixed $key, mixed $member, mixed ...$other_members) Remove sorted set members @@ -78,8 +78,6 @@ * @method false|int|Redis|RedisCluster bitcount(string $key, int $start = 0, int $end = -1, bool $bybit = false) * @method false|int|Redis|RedisCluster bitop(string $operation, string $deskey, string $srckey, string ...$other_keys) * @method false|int|Redis|RedisCluster bitpos(string $key, bool $bit, int $start = 0, int $end = -1, bool $bybit = false) - * @method null|array|false|Redis|RedisCluster blPop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking left pop from list - * @method null|array|false|Redis|RedisCluster brPop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking right pop from list * @method false|Redis|RedisCluster|string brpoplpush(string $src, string $dst, float|int $timeout) * @method array|false|Redis|RedisCluster bzPopMax(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) * @method array|false|Redis|RedisCluster bzPopMin(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) @@ -88,7 +86,6 @@ * @method null|array|false|Redis|RedisCluster blmpop(float $timeout, array $keys, string $from, int $count = 1) * @method null|array|false|Redis|RedisCluster lmpop(array $keys, string $from, int $count = 1) * @method bool clearLastError() - * @method mixed client(string $opt = '', mixed ...$args) * @method mixed command(string|null $opt = null, mixed ...$args) * @method mixed config(string $operation, array|string|null $key_or_settings = null, string|null $value = null) * @method bool connect(string $host, int $port = 6379, float $timeout = 0, string|null $persistent_id = null, int $retry_interval = 0, float $read_timeout = 0, array|null $context = null) @@ -115,7 +112,6 @@ * @method mixed fcall(string $fn, array $keys = [], array $args = []) * @method mixed fcall_ro(string $fn, array $keys = [], array $args = []) * @method bool|Redis|RedisCluster flushAll(bool|null $sync = null) - * @method mixed flushDB(mixed ...$arguments) Flush database * @method array|bool|Redis|string function(string $operation, mixed ...$args) * @method false|int|Redis|RedisCluster geoadd(string $key, float $lng, float $lat, string $member, mixed ...$other_triples_and_options) * @method false|float|Redis|RedisCluster geodist(string $key, string $src, string $dst, string|null $unit = null) @@ -146,7 +142,6 @@ * @method array getTransferredBytes() * @method void clearTransferredBytes() * @method array|false|Redis|RedisCluster getWithMeta(string $key) - * @method false|int|Redis|RedisCluster hDel(string $key, string $field, string ...$other_fields) Delete hash fields * @method array|false|Redis|RedisCluster hexpire(string $key, int $ttl, array $fields, string|null $mode = null) * @method array|false|Redis|RedisCluster hpexpire(string $key, int $ttl, array $fields, string|null $mode = null) * @method array|false|Redis|RedisCluster hexpireat(string $key, int $time, array $fields, string|null $mode = null) @@ -157,20 +152,13 @@ * @method array|false|Redis|RedisCluster hpexpiretime(string $key, array $fields) * @method array|false|Redis|RedisCluster hpersist(string $key, array $fields) * @method bool|Redis|RedisCluster hExists(string $key, string $field) - * @method mixed hGet(string $key, string $member) Get hash field value * @method array|false|Redis|RedisCluster hGetAll(string $key) * @method mixed hGetWithMeta(string $key, string $member) * @method array|false|Redis|RedisCluster hgetdel(string $key, array $fields) * @method array|false|Redis|RedisCluster hgetex(string $key, array $fields, string|array|null $expiry = null) * @method false|int|Redis|RedisCluster hIncrBy(string $key, string $field, int $value) * @method false|float|Redis|RedisCluster hIncrByFloat(string $key, string $field, float $value) - * @method array|false|Redis|RedisCluster hKeys(string $key) Get all hash field names - * @method false|int|Redis|RedisCluster hLen(string $key) Get number of hash fields - * @method array|false|Redis|RedisCluster hMget(string $key, array $fields) Get hash field values - * @method bool|Redis|RedisCluster hMset(string $key, array $fieldValues) Set hash field values * @method array|false|Redis|RedisCluster|string hRandField(string $key, array|null $options = null) - * @method false|int|Redis|RedisCluster hSet(string $key, mixed ...$fields_and_vals) Set hash field values - * @method bool|int|Redis|RedisCluster hSetNx(string $hash, string $key, mixed $value) Set hash field if not exists * @method false|int|Redis|RedisCluster hsetex(string $key, array $fields, array|null $expiry = null) * @method false|int|Redis|RedisCluster hStrLen(string $key, string $field) * @method array|false|Redis|RedisCluster hVals(string $key) @@ -181,7 +169,6 @@ * @method bool isConnected() * @method array|false|Redis|RedisCluster keys(string $pattern) * @method false|int|Redis|RedisCluster lInsert(string $key, string $pos, mixed $pivot, mixed $value) - * @method false|int|Redis|RedisCluster lLen(string $key) Get list length * @method false|Redis|RedisCluster|string lMove(string $src, string $dst, string $wherefrom, string $whereto) * @method false|Redis|RedisCluster|string blmove(string $src, string $dst, string $wherefrom, string $whereto, float $timeout) * @method array|bool|Redis|RedisCluster|string lPop(string $key, int $count = 0) @@ -230,10 +217,8 @@ * @method array|false|Redis|RedisCluster sInter(array|string $key, string ...$other_keys) * @method false|int|Redis|RedisCluster sintercard(array $keys, int $limit = -1) * @method false|int|Redis|RedisCluster sInterStore(array|string $key, string ...$other_keys) - * @method array|false|Redis|RedisCluster sMembers(string $key) Get all set members * @method array|false|Redis|RedisCluster sMisMember(string $key, string $member, string ...$other_members) * @method bool|Redis|RedisCluster sMove(string $src, string $dst, mixed $value) - * @method mixed sPop(string $key, int $count = 0) Remove and return random set member * @method mixed sRandMember(string $key, int $count = 0) * @method array|false|Redis|RedisCluster sUnion(string $key, string ...$other_keys) * @method false|int|Redis|RedisCluster sUnionStore(string $dst, string $key, string ...$other_keys) @@ -253,7 +238,6 @@ * @method mixed slowlog(string $operation, int $length = 0) * @method mixed sort(string $key, array|null $options = null) * @method mixed sort_ro(string $key, array|null $options = null) - * @method false|int|Redis|RedisCluster srem(string $key, mixed $value, mixed ...$other_values) Remove members from set * @method false|int|Redis|RedisCluster strlen(string $key) * @method array|bool|Redis sunsubscribe(array $channels) * @method bool|Redis swapdb(int $src, int $dst) @@ -293,9 +277,6 @@ * @method array|bool|Redis|RedisCluster xreadgroup(string $group, string $consumer, array $streams, int $count = 1, int $block = 1) * @method array|bool|Redis|RedisCluster xrevrange(string $key, int|string $end, int|string $start, int $count = -1) * @method false|int|Redis|RedisCluster xtrim(string $key, int|string $threshold, bool $approx = false, bool $minid = false, int $limit = -1) - * @method false|float|int|Redis|RedisCluster zAdd(string $key, array|float $score_or_options, mixed ...$more_scores_and_mems) Add members to sorted set - * @method false|int|Redis|RedisCluster zCard(string $key) Get sorted set cardinality - * @method false|int|Redis|RedisCluster zCount(string $key, float|int|string $start, float|int|string $end) Count sorted set members by score range * @method false|float|Redis|RedisCluster zIncrBy(string $key, float $value, mixed $member) * @method false|int|Redis|RedisCluster zLexCount(string $key, string $min, string $max) * @method array|false|Redis|RedisCluster zMscore(string $key, mixed $member, mixed ...$other_members) @@ -303,17 +284,14 @@ * @method array|false|Redis|RedisCluster zPopMin(string $key, int|null $count = null) * @method array|false|Redis|RedisCluster zRange(string $key, string|int $start, string|int $end, array|bool|null $options = null) * @method array|false|Redis|RedisCluster zRangeByLex(string $key, string $min, string $max, int $offset = -1, int $count = -1) - * @method array|false|Redis|RedisCluster zRangeByScore(string $key, float|int|string $min, float|int|string $max, array $options = []) Get sorted set members by score range * @method false|int|Redis|RedisCluster zrangestore(string $dstkey, string $srckey, string $start, string $end, array|bool|null $options = null) * @method array|Redis|RedisCluster|string zRandMember(string $key, array|null $options = null) * @method false|int|Redis|RedisCluster zRank(string $key, mixed $member) - * @method false|int|Redis|RedisCluster zRem(mixed $key, mixed $member, mixed ...$other_members) Remove sorted set members * @method false|int|Redis|RedisCluster zRemRangeByLex(string $key, string $min, string $max) * @method false|int|Redis|RedisCluster zRemRangeByRank(string $key, int $start, int $end) * @method false|int|Redis|RedisCluster zRemRangeByScore(string $key, float|int|string $start, float|int|string $end) * @method array|false|Redis|RedisCluster zRevRange(string $key, int $start, int $end, mixed $scores = null) * @method array|false|Redis|RedisCluster zRevRangeByLex(string $key, string $max, string $min, int $offset = -1, int $count = -1) - * @method array|false|Redis|RedisCluster zRevRangeByScore(string $key, float|int|string $max, float|int|string $min, array $options = []) Get sorted set members by score range (reverse) * @method false|int|Redis|RedisCluster zRevRank(string $key, mixed $member) * @method false|float|Redis|RedisCluster zScore(string $key, mixed $member) * @method array|false|Redis|RedisCluster zdiff(array $keys, array|null $options = null) @@ -638,7 +616,7 @@ protected function phpRedisOption(string $name): int 'compression' => Redis::OPT_COMPRESSION, 'reply_literal' => Redis::OPT_REPLY_LITERAL, 'compression_level' => Redis::OPT_COMPRESSION_LEVEL, - 'pack_ignore_numbers' => Redis::OPT_PACK_IGNORE_NUMBERS, // @phpstan-ignore classConstant.notFound (setOptions() guards this option before resolving the constant) + 'pack_ignore_numbers' => Redis::OPT_PACK_IGNORE_NUMBERS, 'max_retries' => Redis::OPT_MAX_RETRIES, 'backoff_algorithm' => Redis::OPT_BACKOFF_ALGORITHM, 'backoff_base' => Redis::OPT_BACKOFF_BASE, @@ -756,10 +734,11 @@ public function release(): void try { $this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL); - } catch (CanceledException $cancellation) { - $this->releaseAfterCancellation($cancellation); - } catch (Throwable) { + } catch (Throwable $loggingException) { // Reporting must not prevent terminal ownership cleanup. + if (Coroutine::causedByCancellation($loggingException)) { + $this->releaseAfterCancellation($loggingException); + } } $this->resetReleaseState(true); @@ -778,11 +757,12 @@ public function release(): void : 'Discarding Redis connection left in WATCH state.', LogLevel::CRITICAL ); - } catch (CanceledException $cancellation) { - // Native close must not start while cancellation is unwinding. - $this->releaseAfterCancellation($cancellation); - } catch (Throwable) { - // Reporting must not prevent terminal ownership cleanup. + } catch (Throwable $loggingException) { + // Native close must not start while cancellation is unwinding, and reporting + // must not prevent terminal ownership cleanup. + if (Coroutine::causedByCancellation($loggingException)) { + $this->releaseAfterCancellation($loggingException); + } } } @@ -827,10 +807,11 @@ public function release(): void if ($cancellation === null) { try { $this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL); - } catch (CanceledException $loggingCancellation) { - $cancellation = $loggingCancellation; - } catch (Throwable) { + } catch (Throwable $loggingException) { // Reporting must not prevent terminal ownership cleanup. + if (Coroutine::causedByCancellation($loggingException)) { + $cancellation = $loggingException; + } } } } @@ -858,7 +839,7 @@ private function resetReleaseState(bool $availableForReuse): void /** * Return an invalid connection to its pool after cancellation. */ - private function releaseAfterCancellation(CanceledException $cancellation): never + private function releaseAfterCancellation(Throwable $cancellation): never { $this->markInvalid(); $this->resetReleaseState(true); diff --git a/src/redis/src/RedisManager.php b/src/redis/src/RedisManager.php index a6dfcda1ee..caa54713d8 100644 --- a/src/redis/src/RedisManager.php +++ b/src/redis/src/RedisManager.php @@ -8,12 +8,12 @@ use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Redis\Connection as ConnectionContract; use Hypervel\Contracts\Redis\Factory as FactoryContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\Events\CommandFailed; use Hypervel\Redis\Limiters\ConcurrencyLimiterBuilder; use Hypervel\Redis\Limiters\DurationLimiterBuilder; use Hypervel\Redis\Pool\PoolManager; -use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; @@ -103,9 +103,7 @@ public function purge(UnitEnum|string|null $name = null): void try { $this->poolManager->purge($poolName); } catch (Throwable $throwable) { - if ($exception === null || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { @@ -204,9 +202,7 @@ protected function terminateConnections(Closure $terminate): void try { $terminate($connection); } catch (Throwable $throwable) { - if ($exception === null || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index 78bd50ccb3..41212c1fbf 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -707,9 +707,11 @@ public function subscriber(): Subscriber $config['scheme'], $config['context'], ); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $failures[] = sprintf( '[%s:%d]: %s', $master[0], diff --git a/src/redis/src/RedisSentinelFactory.php b/src/redis/src/RedisSentinelFactory.php index bb8519371d..5479e20abe 100644 --- a/src/redis/src/RedisSentinelFactory.php +++ b/src/redis/src/RedisSentinelFactory.php @@ -18,7 +18,7 @@ class RedisSentinelFactory public function create(array $options = []): RedisSentinel { // https://github.com/phpredis/phpredis/blob/develop/sentinel.md#examples-for-version-60-or-later - return new RedisSentinel($options); // @phpstan-ignore arguments.count, argument.type + return new RedisSentinel($options); } /** diff --git a/src/reflection/src/ClassMetadataCache.php b/src/reflection/src/ClassMetadataCache.php index 0537d460e0..58d75a410a 100644 --- a/src/reflection/src/ClassMetadataCache.php +++ b/src/reflection/src/ClassMetadataCache.php @@ -32,6 +32,11 @@ class ClassMetadataCache */ protected static array $properties = []; + /** + * @var array> + */ + protected static array $allProperties = []; + /** * @var array> */ @@ -114,6 +119,36 @@ public static function properties(object|string $target): array ??= static::reflectClass($class)->getProperties(); } + /** + * Get the cached properties for the given class, followed by the private properties its parents declare. + * + * Reflection omits a parent's private properties from the class's properties, though every + * instance of the class holds them. + * + * @param class-string|object $target + * @return list + * + * @throws ReflectionException + */ + public static function allProperties(object|string $target): array + { + $class = static::className($target); + + if (isset(static::$allProperties[$class])) { + return static::$allProperties[$class]; + } + + $properties = static::properties($class); + + for ($parent = static::reflectClass($class)->getParentClass(); $parent !== false; $parent = $parent->getParentClass()) { + foreach ($parent->getProperties(ReflectionProperty::IS_PRIVATE) as $property) { + $properties[] = $property; + } + } + + return static::$allProperties[$class] = $properties; + } + /** * Determine if the class uses a trait directly, through another trait or through a parent. * @@ -285,6 +320,7 @@ public static function flushState(): void static::$methods = []; static::$defaultProperties = []; static::$properties = []; + static::$allProperties = []; static::$traits = []; static::$attributes = []; static::$classAttributePresence = []; diff --git a/src/reflection/src/Reflector.php b/src/reflection/src/Reflector.php index 9ac84382d2..4b8092d6a7 100644 --- a/src/reflection/src/Reflector.php +++ b/src/reflection/src/Reflector.php @@ -116,13 +116,17 @@ public static function getParameterClassName(ReflectionParameter $parameter): ?s /** * Get the class names of the given parameter's type, including union types. + * + * @return list */ public static function getParameterClassNames(ReflectionParameter $parameter): array { $type = $parameter->getType(); if (! $type instanceof ReflectionUnionType) { - return array_filter([static::getParameterClassName($parameter)]); + $className = static::getParameterClassName($parameter); + + return $className === null ? [] : [$className]; } $unionTypes = []; @@ -135,7 +139,7 @@ public static function getParameterClassNames(ReflectionParameter $parameter): a $unionTypes[] = static::getTypeName($parameter, $listedType); } - return array_filter($unionTypes); + return $unionTypes; } /** diff --git a/src/reverb/composer.json b/src/reverb/composer.json index 94eba6d55e..d8ba16a591 100644 --- a/src/reverb/composer.json +++ b/src/reverb/composer.json @@ -58,8 +58,8 @@ "hypervel/validation": "^0.4", "hypervel/websocket-server": "^0.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "config": { "sort-packages": true diff --git a/src/reverb/src/FailureReporter.php b/src/reverb/src/FailureReporter.php index 19a2bcd7eb..7f49b5cb60 100644 --- a/src/reverb/src/FailureReporter.php +++ b/src/reverb/src/FailureReporter.php @@ -5,6 +5,7 @@ namespace Hypervel\Reverb; use Hypervel\Contracts\Debug\ExceptionHandler; +use Swoole\Coroutine\CanceledException; use Throwable; class FailureReporter @@ -14,6 +15,11 @@ class FailureReporter */ public static function report(Throwable $throwable): void { + // @phpstan-ignore hypervel.cancellation.exactType (a terminal reporter: a cleanup failure carrying cancellation is still reported) + if ($throwable instanceof CanceledException) { + return; + } + try { app(ExceptionHandler::class)->report($throwable); } catch (Throwable $reportingFailure) { diff --git a/src/reverb/src/Jobs/PingInactiveConnections.php b/src/reverb/src/Jobs/PingInactiveConnections.php index 4c48f5d556..d973f429ed 100644 --- a/src/reverb/src/Jobs/PingInactiveConnections.php +++ b/src/reverb/src/Jobs/PingInactiveConnections.php @@ -4,6 +4,7 @@ namespace Hypervel\Reverb\Jobs; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Bus\Dispatchable; use Hypervel\Reverb\Loggers\Log; use Hypervel\Reverb\Protocols\Pusher\Contracts\ChannelManager; @@ -40,7 +41,7 @@ public function handle(ChannelManager $channels): void Log::info('Connection Pinged', $connection->id()); }); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/reverb/src/Jobs/PruneStaleConnections.php b/src/reverb/src/Jobs/PruneStaleConnections.php index e4bba068a7..2bbdb9cb12 100644 --- a/src/reverb/src/Jobs/PruneStaleConnections.php +++ b/src/reverb/src/Jobs/PruneStaleConnections.php @@ -4,9 +4,11 @@ namespace Hypervel\Reverb\Jobs; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Bus\Dispatchable; use Hypervel\Reverb\Events\ConnectionPruned; use Hypervel\Reverb\Loggers\Log; +use Hypervel\Reverb\Protocols\Pusher\Channels\ChannelConnection; use Hypervel\Reverb\Servers\Hypervel\ConnectionLifecycle; use Hypervel\Reverb\Servers\Hypervel\WebSocketHandler; use Throwable; @@ -50,17 +52,17 @@ public function handle(): void // The onClose callback owns protocol and shared-state cleanup. $connection->disconnect(); } catch (Throwable $throwable) { - $connectionFailure ??= $throwable; + $connectionFailure = Coroutine::preferCancellation($connectionFailure, $throwable); } try { Log::info('Connection Pruned', $connection->id()); if (app('events')->hasListeners(ConnectionPruned::class)) { - ConnectionPruned::dispatch($connection); + ConnectionPruned::dispatch(new ChannelConnection($connection)); } } catch (Throwable $throwable) { - $connectionFailure ??= $throwable; + $connectionFailure = Coroutine::preferCancellation($connectionFailure, $throwable); } if ($connectionFailure !== null) { @@ -68,7 +70,7 @@ public function handle(): void } }); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/reverb/src/Protocols/Pusher/Channels/Channel.php b/src/reverb/src/Protocols/Pusher/Channels/Channel.php index d6d6e1e83d..07c9faa9b8 100644 --- a/src/reverb/src/Protocols/Pusher/Channels/Channel.php +++ b/src/reverb/src/Protocols/Pusher/Channels/Channel.php @@ -4,6 +4,7 @@ namespace Hypervel\Reverb\Protocols\Pusher\Channels; +use Hypervel\Coroutine\Coroutine; use Hypervel\Reverb\Contracts\Connection; use Hypervel\Reverb\Events\ChannelCreated; use Hypervel\Reverb\Events\ChannelRemoved; @@ -16,7 +17,6 @@ use Hypervel\Reverb\Servers\Hypervel\Scaling\SubscriptionResult; use Hypervel\Reverb\Webhooks\Contracts\WebhookDispatcher; use Hypervel\Reverb\Webhooks\DeferredWebhookManager; -use Swoole\Coroutine\CanceledException; use Throwable; class Channel @@ -388,9 +388,11 @@ protected function sendToConnections(string $message, ?Connection $except = null try { $connection->send($message); - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } diff --git a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php index 4c8696c9eb..cc3e2a74f4 100644 --- a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php +++ b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php @@ -4,6 +4,7 @@ namespace Hypervel\Reverb\Protocols\Pusher\Channels\Concerns; +use Hypervel\Coroutine\Coroutine; use Hypervel\Reverb\Contracts\Connection; use Hypervel\Reverb\FailureReporter; use Hypervel\Reverb\Protocols\Pusher\Channels\ChannelConnection; @@ -13,7 +14,6 @@ use Hypervel\Reverb\Servers\Hypervel\Contracts\SharedState; use Hypervel\Reverb\Webhooks\Contracts\WebhookDispatcher; use Hypervel\Reverb\Webhooks\DeferredWebhookManager; -use Swoole\Coroutine\CanceledException; use Throwable; trait InteractsWithPresenceChannels @@ -163,9 +163,11 @@ public function data(): array MetricType::Presence->value, ['channel' => $this->name()], ); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + // The subscription is already committed, so still answer it with // this worker's members when other workers or servers can't be reached. FailureReporter::report($exception); diff --git a/src/reverb/src/Protocols/Pusher/EventDispatcher.php b/src/reverb/src/Protocols/Pusher/EventDispatcher.php index 176efd87dd..08d58874ee 100644 --- a/src/reverb/src/Protocols/Pusher/EventDispatcher.php +++ b/src/reverb/src/Protocols/Pusher/EventDispatcher.php @@ -4,6 +4,7 @@ namespace Hypervel\Reverb\Protocols\Pusher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Reverb\Application; use Hypervel\Reverb\Contracts\Connection; use Hypervel\Reverb\FailureReporter; @@ -16,7 +17,6 @@ use Hypervel\Reverb\Servers\Hypervel\Contracts\SharedState; use Hypervel\Support\Arr; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Swoole\Server; use Throwable; @@ -87,9 +87,11 @@ public static function dispatchSynchronously( if ($channel instanceof CacheChannel) { app(SharedState::class)->clearCacheMissLock($app->id(), $channel->name()); } - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } @@ -102,7 +104,7 @@ public static function dispatchSynchronously( try { static::fanOutToOtherWorkers($app, $channels, $payload, $connection, socketId: $socketId); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -133,9 +135,11 @@ public static function dispatchInternallySynchronously(Application $app, array $ $payload['channel'] = $channel->name(); $channel->broadcastInternally($payload, $connection); - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } @@ -146,7 +150,7 @@ public static function dispatchInternallySynchronously(Application $app, array $ try { static::fanOutToOtherWorkers($app, $channels, $payload, $connection, internal: true); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -178,9 +182,11 @@ public static function dispatchInternalToChannel(Application $app, Channel $chan try { $channel->broadcastInternally($payload, $connection); - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception = $throwable; } @@ -188,7 +194,7 @@ public static function dispatchInternalToChannel(Application $app, Channel $chan unset($payload['channel']); static::fanOutToOtherWorkers($app, [$channel->name()], $payload, $connection, internal: true); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } else { $data = [ @@ -204,9 +210,11 @@ public static function dispatchInternalToChannel(Application $app, Channel $chan try { app(PubSubProvider::class)->publish($data); - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception = $throwable; } } @@ -258,7 +266,7 @@ protected static function fanOutToOtherWorkers( ); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/reverb/src/Protocols/Pusher/Http/Controllers/UsersTerminateController.php b/src/reverb/src/Protocols/Pusher/Http/Controllers/UsersTerminateController.php index ce8d316d9c..8e759656d4 100644 --- a/src/reverb/src/Protocols/Pusher/Http/Controllers/UsersTerminateController.php +++ b/src/reverb/src/Protocols/Pusher/Http/Controllers/UsersTerminateController.php @@ -4,6 +4,7 @@ namespace Hypervel\Reverb\Protocols\Pusher\Http\Controllers; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Reverb\Protocols\Pusher\UserConnectionTerminator; @@ -57,7 +58,7 @@ public function __invoke(Request $request, string $appId, string $userId): JsonR ); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/reverb/src/Protocols/Pusher/Managers/ArrayChannelManager.php b/src/reverb/src/Protocols/Pusher/Managers/ArrayChannelManager.php index faefc9a74b..9cff6fb138 100644 --- a/src/reverb/src/Protocols/Pusher/Managers/ArrayChannelManager.php +++ b/src/reverb/src/Protocols/Pusher/Managers/ArrayChannelManager.php @@ -4,6 +4,7 @@ namespace Hypervel\Reverb\Protocols\Pusher\Managers; +use Hypervel\Coroutine\Coroutine; use Hypervel\Reverb\Application; use Hypervel\Reverb\Contracts\Connection; use Hypervel\Reverb\Protocols\Pusher\Channels\Channel; @@ -130,7 +131,7 @@ public function unsubscribeFromAllChannels(string $appId, Connection $connection try { $channel->unsubscribe($connection); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/reverb/src/Protocols/Pusher/MetricsHandler.php b/src/reverb/src/Protocols/Pusher/MetricsHandler.php index ae51749cb8..23d2ac0ea5 100644 --- a/src/reverb/src/Protocols/Pusher/MetricsHandler.php +++ b/src/reverb/src/Protocols/Pusher/MetricsHandler.php @@ -4,6 +4,7 @@ namespace Hypervel\Reverb\Protocols\Pusher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Reverb\Application; use Hypervel\Reverb\Protocols\Pusher\Concerns\InteractsWithChannelInformation; use Hypervel\Reverb\Protocols\Pusher\Contracts\ChannelManager; @@ -256,7 +257,7 @@ protected function gatherMetricsFromWorkers(PendingMetric $metric, int $workerCo ); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/reverb/src/Protocols/Pusher/Server.php b/src/reverb/src/Protocols/Pusher/Server.php index 750f6fef4b..da068adb77 100644 --- a/src/reverb/src/Protocols/Pusher/Server.php +++ b/src/reverb/src/Protocols/Pusher/Server.php @@ -4,6 +4,7 @@ namespace Hypervel\Reverb\Protocols\Pusher; +use Hypervel\Coroutine\Coroutine; use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Limiter; use Hypervel\Reverb\Contracts\Connection; @@ -21,7 +22,6 @@ use Hypervel\Reverb\Servers\Hypervel\Contracts\SharedState; use Hypervel\Support\Str; use JsonException; -use Swoole\Coroutine\CanceledException; use Throwable; class Server @@ -68,14 +68,14 @@ public function open(Connection $connection): void } } - if ($e instanceof CanceledException) { + if (Coroutine::causedByCancellation($e)) { throw $e; } try { $this->error($connection, $e); } catch (Throwable $throwable) { - $cleanupFailure ??= $throwable; + $cleanupFailure = Coroutine::preferCancellation($cleanupFailure, $throwable); } if ($cleanupFailure !== null) { @@ -138,9 +138,11 @@ public function message(Connection $from, string $message): void if (app('events')->hasListeners(MessageReceived::class)) { MessageReceived::dispatch($from, $message); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + $terminateOnLimit = $e instanceof RateLimitExceeded && $from->app()->rateLimiting()['terminate_on_limit']; @@ -198,7 +200,7 @@ public function close(Connection $connection): void app(SharedState::class)->releaseConnectionSlot($connection->app()->id()); $connection->clearConnectionSlotAcquired(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -207,7 +209,7 @@ public function close(Connection $connection): void $this->messageRateLimiter->clear($this->messageLimit($connection)); $connection->clearRateLimiterInitialized(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -219,7 +221,7 @@ public function close(Connection $connection): void ConnectionClosed::dispatch($connection); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/reverb/src/Protocols/Pusher/UserConnectionTerminator.php b/src/reverb/src/Protocols/Pusher/UserConnectionTerminator.php index 59334b874b..10198c86c5 100644 --- a/src/reverb/src/Protocols/Pusher/UserConnectionTerminator.php +++ b/src/reverb/src/Protocols/Pusher/UserConnectionTerminator.php @@ -4,6 +4,7 @@ namespace Hypervel\Reverb\Protocols\Pusher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Reverb\Application; use Hypervel\Reverb\Protocols\Pusher\Contracts\ChannelManager; use Throwable; @@ -33,7 +34,7 @@ public function terminate(Application $application, string $userId): void try { $connection->disconnect(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/reverb/src/ReverbServiceProvider.php b/src/reverb/src/ReverbServiceProvider.php index 4292119a6f..2c552ccafa 100644 --- a/src/reverb/src/ReverbServiceProvider.php +++ b/src/reverb/src/ReverbServiceProvider.php @@ -10,6 +10,7 @@ use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Core\Events\OnPipeMessage; use Hypervel\Core\Events\OnWorkerExit; +use Hypervel\Coroutine\Coroutine; use Hypervel\RateLimiter\KeyResolver; use Hypervel\RateLimiter\Limiter; use Hypervel\RateLimiter\WorkerArrayStore; @@ -62,7 +63,6 @@ use Hypervel\Support\Facades\Log; use Hypervel\Support\ServiceProvider; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Swoole\Table; use Throwable; @@ -357,7 +357,7 @@ protected function registerPipeMessageListener(): void } } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -432,7 +432,7 @@ public function drainConnections(): void fn () => $lifecycle->connection()?->disconnect(1001, 'Server restarting'), ); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -489,9 +489,11 @@ protected function scheduleWebhookFlushes(): void FlushWebhookBatchJob::dispatch($app->id(), $webhooks) ->onQueue('reverb-webhook-flush'); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + // An unavailable Redis Cluster slot must not stop other applications' flushes. FailureReporter::report($exception); } diff --git a/src/reverb/src/Servers/Hypervel/HttpServer.php b/src/reverb/src/Servers/Hypervel/HttpServer.php index 7ad05d29cf..fb0a09eab0 100644 --- a/src/reverb/src/Servers/Hypervel/HttpServer.php +++ b/src/reverb/src/Servers/Hypervel/HttpServer.php @@ -12,13 +12,15 @@ use Hypervel\Contracts\Server\OnRequestInterface; use Hypervel\Coordinator\Constants; use Hypervel\Coordinator\CoordinatorManager; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; +use Hypervel\HttpServer\Events\RequestCanceled; use Hypervel\HttpServer\Events\RequestHandled; use Hypervel\HttpServer\Events\RequestReceived; use Hypervel\HttpServer\Events\ResponseSent; use Hypervel\HttpServer\RequestBridge; use Hypervel\HttpServer\ResponseBridge; -use Swoole\Coroutine\CanceledException; +use Hypervel\Support\FailureReporter; use Swoole\Http\Request as SwooleRequest; use Swoole\Http\Response as SwooleResponse; use Symfony\Component\HttpFoundation\Response; @@ -87,24 +89,35 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe $response = $this->router->dispatch($request); } - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $request === null ? $throwable : $this->requestCanceled($request, null, null, $throwable); + } + // Keep the original in flight while it is handled and emitted, so // any failure at that boundary carries the root cause as previous. // The return suppresses it once the response has been emitted. try { - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ throw $throwable; } finally { - $handler = $this->container->make(ExceptionHandler::class); - $handler->report($throwable); - $response = $request !== null - ? $handler->render($request, $throwable) - : new Response('Internal Server Error', 500); + try { + $handler = $this->container->make(ExceptionHandler::class); + $handler->report($throwable); + $response = $request !== null + ? $handler->render($request, $throwable) + : new Response('Internal Server Error', 500); + } catch (Throwable $failure) { + if ($request !== null && Coroutine::causedByCancellation($failure)) { + $failure = $this->requestCanceled($request, null, $throwable, $failure); + } + + throw $failure; + } + $this->sendResponse($request, $response, $swooleResponse, $throwable); - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ return; } } @@ -122,47 +135,71 @@ protected function sendResponse( ?Throwable $exception = null, ): void { $terminalException = null; + $responseSent = false; - if ($request !== null) { - try { - if ($this->event?->hasListeners(RequestHandled::class)) { - $this->event->dispatch(new RequestHandled( - request: $request, - response: $response, - exception: $exception, - server: $this->serverName, - )); + try { + if ($request !== null) { + try { + if ($this->event?->hasListeners(RequestHandled::class)) { + $this->event->dispatch(new RequestHandled( + request: $request, + response: $response, + exception: $exception, + server: $this->serverName, + )); + } + } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + + $terminalException = $throwable; } - } catch (CanceledException $throwable) { - throw $throwable; + } + + try { + ResponseBridge::send($response, $swooleResponse, request: $request); } catch (Throwable $throwable) { - $terminalException = $throwable; + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + + $terminalException = FailureReporter::preferCancellation($terminalException, $throwable); } - } - try { - ResponseBridge::send($response, $swooleResponse, request: $request); - } catch (CanceledException $throwable) { - throw $throwable; - } catch (Throwable $throwable) { - $terminalException ??= $throwable; - } + $responseSent = true; - if ($request !== null) { - try { - if ($this->event?->hasListeners(ResponseSent::class)) { - $this->event->dispatch(new ResponseSent( - request: $request, - response: $response, - exception: $exception ?? $terminalException, - server: $this->serverName, - )); + if ($request !== null) { + try { + if ($this->event?->hasListeners(ResponseSent::class)) { + $this->event->dispatch(new ResponseSent( + request: $request, + response: $response, + exception: $exception ?? $terminalException, + server: $this->serverName, + )); + } + } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + + $terminalException = FailureReporter::preferCancellation($terminalException, $throwable); } - } catch (CanceledException $throwable) { - throw $throwable; - } catch (Throwable $throwable) { - $terminalException ??= $throwable; } + } catch (Throwable $cancellation) { + // Only cancellation reaches here; every other failure is kept above. The original + // failure was already reported, but a later one would otherwise be lost. + if ($terminalException !== null) { + FailureReporter::report($terminalException); + } + + throw $request === null ? $cancellation : $this->requestCanceled( + $request, + $responseSent ? $response : null, + $exception ?? $terminalException, + $cancellation, + ); } if ($terminalException !== null) { @@ -170,6 +207,35 @@ protected function sendResponse( } } + /** + * Notify listeners that cancellation stopped the request lifecycle. + * + * Returns the throwable to propagate, so a listener failure never replaces the cancellation. + */ + protected function requestCanceled( + Request $request, + ?Response $response, + ?Throwable $exception, + Throwable $cancellation, + ): Throwable { + try { + if ($this->event?->hasListeners(RequestCanceled::class)) { + $this->event->dispatch(new RequestCanceled( + request: $request, + response: $response, + cancellation: $cancellation, + exception: $exception, + server: $this->serverName, + )); + } + } catch (Throwable $throwable) { + // The first cancellation keeps precedence; the listener failure is still reported. + FailureReporter::report($throwable); + } + + return $cancellation; + } + /** * Determine if the HTTP request exceeds Reverb's request-size limit. */ diff --git a/src/reverb/src/Servers/Hypervel/WebSocketHandler.php b/src/reverb/src/Servers/Hypervel/WebSocketHandler.php index b8b39d4d4a..5d85210dbe 100644 --- a/src/reverb/src/Servers/Hypervel/WebSocketHandler.php +++ b/src/reverb/src/Servers/Hypervel/WebSocketHandler.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Server\OnCloseInterface; use Hypervel\Contracts\Server\OnMessageInterface; use Hypervel\Contracts\Server\OnOpenInterface; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request as HttpRequest; use Hypervel\Reverb\Connection as ReverbConnection; use Hypervel\Reverb\Contracts\ApplicationProvider; @@ -217,7 +218,7 @@ public function closeLifecycle(ConnectionLifecycle $lifecycle, ?callable $discon try { $disconnect(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/reverb/src/Webhooks/HttpWebhookDispatcher.php b/src/reverb/src/Webhooks/HttpWebhookDispatcher.php index aed924afe2..4bf7aab079 100644 --- a/src/reverb/src/Webhooks/HttpWebhookDispatcher.php +++ b/src/reverb/src/Webhooks/HttpWebhookDispatcher.php @@ -4,6 +4,7 @@ namespace Hypervel\Reverb\Webhooks; +use Hypervel\Coroutine\Coroutine; use Hypervel\Reverb\Application; use Hypervel\Reverb\Contracts\Connection; use Hypervel\Reverb\FailureReporter; @@ -12,7 +13,6 @@ use Hypervel\Reverb\Webhooks\Contracts\WebhookSender; use Hypervel\Reverb\Webhooks\Jobs\FlushWebhookBatchJob; use Hypervel\Support\Str; -use Swoole\Coroutine\CanceledException; use Throwable; class HttpWebhookDispatcher implements WebhookDispatcher @@ -67,10 +67,8 @@ public function dispatch(Application $application, string $event, array $data = try { // The append is durable; release only the lock acquired by this call. $buffer->clearFlushLock($application->id()); - } catch (CanceledException $cancellation) { - throw $cancellation; - } catch (Throwable) { - // Preserve the queue dispatch failure. + } catch (Throwable $cleanupFailure) { + $exception = Coroutine::preferCancellation($exception, $cleanupFailure); } throw $exception; @@ -85,9 +83,11 @@ public function dispatch(Application $application, string $event, array $data = app(WebhookSender::class)->send($application, $webhooks, $payload); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + // A delivery failure must not interrupt the WebSocket operation that triggered it. FailureReporter::report($exception); } diff --git a/src/routing/composer.json b/src/routing/composer.json index 79caa90664..3dc278aab9 100644 --- a/src/routing/composer.json +++ b/src/routing/composer.json @@ -48,10 +48,10 @@ "laravel/serializable-closure": "^2.0.11", "psr/http-message": "^2.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/psr-http-message-bridge": "^8.1", - "symfony/routing": "^8.1" + "symfony/routing": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/routing/src/CreatesRegularExpressionRouteConstraints.php b/src/routing/src/CreatesRegularExpressionRouteConstraints.php index cc43b57a74..49261edf0d 100644 --- a/src/routing/src/CreatesRegularExpressionRouteConstraints.php +++ b/src/routing/src/CreatesRegularExpressionRouteConstraints.php @@ -12,6 +12,8 @@ trait CreatesRegularExpressionRouteConstraints { /** * Specify that the given route parameters must be alphabetic. + * + * @return $this */ public function whereAlpha(array|string $parameters): static { @@ -20,6 +22,8 @@ public function whereAlpha(array|string $parameters): static /** * Specify that the given route parameters must be alphanumeric. + * + * @return $this */ public function whereAlphaNumeric(array|string $parameters): static { @@ -28,6 +32,8 @@ public function whereAlphaNumeric(array|string $parameters): static /** * Specify that the given route parameters must be numeric. + * + * @return $this */ public function whereNumber(array|string $parameters): static { @@ -36,6 +42,8 @@ public function whereNumber(array|string $parameters): static /** * Specify that the given route parameters must be ULIDs. + * + * @return $this */ public function whereUlid(array|string $parameters): static { @@ -44,6 +52,8 @@ public function whereUlid(array|string $parameters): static /** * Specify that the given route parameters must be UUIDs. + * + * @return $this */ public function whereUuid(array|string $parameters): static { @@ -52,6 +62,8 @@ public function whereUuid(array|string $parameters): static /** * Specify that the given route parameters must be one of the given values. + * + * @return $this */ public function whereIn(array|string $parameters, array $values): static { @@ -65,6 +77,8 @@ public function whereIn(array|string $parameters, array $values): static /** * Apply the given regular expression to the given parameters. + * + * @return $this */ protected function assignExpressionToParameters(array|string $parameters, string $expression): static { diff --git a/src/routing/src/PendingResourceRegistration.php b/src/routing/src/PendingResourceRegistration.php index 17d0ce636c..40d675a57f 100644 --- a/src/routing/src/PendingResourceRegistration.php +++ b/src/routing/src/PendingResourceRegistration.php @@ -135,6 +135,8 @@ public function middleware(mixed $middleware): static /** * Specify middleware that should be added to the specified resource routes. + * + * @return $this */ public function middlewareFor(array|string $methods, array|string $middleware): static { @@ -157,6 +159,8 @@ public function middlewareFor(array|string $methods, array|string $middleware): /** * Specify middleware that should be removed from the resource routes. + * + * @return $this */ public function withoutMiddleware(array|string $middleware): static { @@ -170,6 +174,8 @@ public function withoutMiddleware(array|string $middleware): static /** * Specify middleware that should be removed from the specified resource routes. + * + * @return $this */ public function withoutMiddlewareFor(array|string $methods, array|string $middleware): static { @@ -185,6 +191,8 @@ public function withoutMiddlewareFor(array|string $methods, array|string $middle /** * Add "where" constraints to the resource routes. + * + * @return $this */ public function where(mixed $wheres): static { @@ -195,6 +203,8 @@ public function where(mixed $wheres): static /** * Add metadata to the registered resource routes. + * + * @return $this */ public function metadata(array $metadata): static { @@ -218,6 +228,8 @@ public function shallow(bool $shallow = true): static /** * Define the callable that should be invoked on a missing model exception. + * + * @return $this */ public function missing(callable $callback): static { diff --git a/src/routing/src/PendingSingletonResourceRegistration.php b/src/routing/src/PendingSingletonResourceRegistration.php index 5de0ec9dc7..179ef0addc 100644 --- a/src/routing/src/PendingSingletonResourceRegistration.php +++ b/src/routing/src/PendingSingletonResourceRegistration.php @@ -70,6 +70,8 @@ public function except(array|string $methods): static /** * Indicate that the resource should have creation and storage routes. + * + * @return $this */ public function creatable(): static { @@ -80,6 +82,8 @@ public function creatable(): static /** * Indicate that the resource should have a deletion route. + * + * @return $this */ public function destroyable(): static { @@ -155,6 +159,8 @@ public function middleware(mixed $middleware): static /** * Specify middleware that should be added to the specified resource routes. + * + * @return $this */ public function middlewareFor(array|string $methods, array|string $middleware): static { @@ -177,6 +183,8 @@ public function middlewareFor(array|string $methods, array|string $middleware): /** * Specify middleware that should be removed from the resource routes. + * + * @return $this */ public function withoutMiddleware(array|string $middleware): static { @@ -190,6 +198,8 @@ public function withoutMiddleware(array|string $middleware): static /** * Specify middleware that should be removed from the specified resource routes. + * + * @return $this */ public function withoutMiddlewareFor(array|string $methods, array|string $middleware): static { @@ -205,6 +215,8 @@ public function withoutMiddlewareFor(array|string $methods, array|string $middle /** * Add "where" constraints to the resource routes. + * + * @return $this */ public function where(mixed $wheres): static { @@ -215,6 +227,8 @@ public function where(mixed $wheres): static /** * Add metadata to the registered singleton resource routes. + * + * @return $this */ public function metadata(array $metadata): static { diff --git a/src/routing/src/Pipeline.php b/src/routing/src/Pipeline.php index be9ebb020a..457c985b15 100644 --- a/src/routing/src/Pipeline.php +++ b/src/routing/src/Pipeline.php @@ -43,7 +43,7 @@ protected function handleException(mixed $passable, Throwable $e): mixed // reporting or rendering carries it as that failure's previous. The // return suppresses it once a response exists. try { - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ throw $e; } finally { $handler = $this->container->make(ExceptionHandler::class); @@ -56,7 +56,7 @@ protected function handleException(mixed $passable, Throwable $e): mixed $response->withException($e); } - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ return $this->handleCarry($response); } } diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index 57510f1ba8..8dd022bd0f 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -8,6 +8,8 @@ use Closure; use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; +use Hypervel\Contracts\Container\SelfBuilding; +use Hypervel\Contracts\Container\Transient; use Hypervel\Http\Exceptions\HttpResponseException; use Hypervel\Http\Request; use Hypervel\Routing\Attributes\Controllers\Middleware as MiddlewareAttribute; @@ -80,6 +82,8 @@ class Route /** * The HTTP methods the route responds to. + * + * @var array */ public array $methods; @@ -378,16 +382,18 @@ private function controllerContextKey(): string /** * Determine if this route's controller may be cached on the shared Route instance. * - * Worker-shared bindings (singletons, auto-singletons) are safe to cache - * on the Route property. Per-coroutine bindings (#[Scoped], explicit bind()) - * must go through Context to preserve the container's binding contract. + * Worker-shared lifetimes (singletons, auto-singletons) are safe to cache + * on the Route property. Per-coroutine and fresh lifetimes (#[Scoped], + * explicit bind(), unbound Transient or SelfBuilding classes) must go + * through Context to preserve the container's binding contract. */ private function shouldCacheControllerOnRoute(string $class): bool { return $this->shouldCacheControllerOnRoute ??= match (true) { $this->container->isScoped($class) => false, - $this->container->bound($class) => $this->container->isShared($class), - default => true, + $this->container->isShared($class) => true, + $this->container->bound($class) => false, + default => ! is_a($class, Transient::class, true) && ! is_a($class, SelfBuilding::class, true), }; } @@ -509,6 +515,8 @@ private function originalParametersContextKey(): string * * Parameters are stored in coroutine Context, not on the Route instance, * because Route objects are cached and shared across coroutines. + * + * @return $this */ public function bind(Request $request): static { @@ -525,6 +533,7 @@ public function bind(Request $request): static * @internal * * @param array $parameters + * @return $this */ public function bindFromCompiledMatch(array $parameters, Request $request): static { @@ -547,6 +556,8 @@ public function bindFromCompiledMatch(array $parameters, Request $request): stat /** * Store the route's current and original parameters for this coroutine. + * + * @return $this */ private function storeParameters(array $parameters): static { @@ -714,6 +725,8 @@ public function bindingFields(): array /** * Set the binding fields for the route. + * + * @return $this */ public function setBindingFields(array $bindingFields): static { @@ -739,6 +752,8 @@ public function parentOfParameter(string $parameter): mixed /** * Allow "trashed" models to be retrieved when resolving implicit model bindings for this route. + * + * @return $this */ public function withTrashed(bool $withTrashed = true): static { @@ -757,6 +772,8 @@ public function allowsTrashedBindings(): bool /** * Set a default value for the route. + * + * @return $this */ public function defaults(string $key, mixed $value): static { @@ -767,6 +784,8 @@ public function defaults(string $key, mixed $value): static /** * Set the default values for the route. + * + * @return $this */ public function setDefaults(array $defaults): static { @@ -777,6 +796,8 @@ public function setDefaults(array $defaults): static /** * Set a regular expression requirement on the route. + * + * @return $this */ public function where(array|string $name, ?string $expression = null): static { @@ -797,6 +818,8 @@ protected function parseWhere(array|string $name, ?string $expression): array /** * Set a list of regular expression requirements on the route. + * + * @return $this */ public function setWheres(array $wheres): static { @@ -809,6 +832,8 @@ public function setWheres(array $wheres): static /** * Mark this route as a fallback route. + * + * @return $this */ public function fallback(): static { @@ -819,6 +844,8 @@ public function fallback(): static /** * Set the fallback value. + * + * @return $this */ public function setFallback(bool $isFallback): static { @@ -829,6 +856,8 @@ public function setFallback(bool $isFallback): static /** * Get the HTTP verbs the route responds to. + * + * @return array */ public function methods(): array { @@ -930,6 +959,8 @@ public function getPrefix(): ?string /** * Add a prefix to the route URI. + * + * @return $this */ public function prefix(?string $prefix): static { @@ -962,6 +993,8 @@ public function uri(): string /** * Set the URI that the route responds to. + * + * @return $this */ public function setUri(string $uri): static { @@ -993,6 +1026,8 @@ public function getName(): ?string /** * Add or change the route name. * + * @return $this + * * @throws InvalidArgumentException */ public function name(BackedEnum|string $name): static @@ -1027,6 +1062,8 @@ public function named(mixed ...$patterns): bool /** * Set the handler for the route. + * + * @return $this */ public function uses(Closure|array|string $action): static { @@ -1082,6 +1119,8 @@ public function getAction(?string $key = null): mixed /** * Set the action array for the route. + * + * @return $this */ public function setAction(array $action): static { @@ -1132,6 +1171,8 @@ public function getMissing(): ?Closure /** * Define the callable that should be invoked on a missing model exception. + * + * @return $this */ public function missing(Closure $missing): static { @@ -1161,7 +1202,7 @@ public function gatherMiddleware(): array /** * Get or set the middlewares attached to the route. * - * @return ($middleware is null ? array : $this) + * @return ($middleware is null ? array : $this) */ public function middleware(array|string|Stringable|null $middleware = null): static|array { @@ -1187,6 +1228,8 @@ public function middleware(array|string|Stringable|null $middleware = null): sta /** * Specify that the "Authorize" / "can" middleware should be applied to the route. + * + * @return $this */ public function can(UnitEnum|string $ability, array|string $models = []): static { @@ -1245,7 +1288,7 @@ protected function staticallyProvidedControllerMiddleware(string $class, string }) ->map ->middleware - ->flatten() // @phpstan-ignore method.nonObject (HigherOrderCollectionProxy result can't be statically typed) + ->flatten() ->values() ->all(); } @@ -1358,6 +1401,8 @@ protected function attributeProvidedControllerMiddlewareExclusions(string $class /** * Specify middleware that should be removed from the given route. + * + * @return $this */ public function withoutMiddleware(array|string $middleware): static { @@ -1382,6 +1427,8 @@ public function excludedMiddleware(): array /** * Indicate that the route should enforce scoping of multiple implicit Eloquent bindings. + * + * @return $this */ public function scopeBindings(): static { @@ -1392,6 +1439,8 @@ public function scopeBindings(): static /** * Indicate that the route should not enforce scoping of multiple implicit Eloquent bindings. + * + * @return $this */ public function withoutScopedBindings(): static { @@ -1418,6 +1467,8 @@ public function preventsScopedBindings(): bool /** * Specify that the route should not allow concurrent requests from the same session. + * + * @return $this */ public function block(?int $lockSeconds = 10, ?int $waitSeconds = 10): static { @@ -1429,6 +1480,8 @@ public function block(?int $lockSeconds = 10, ?int $waitSeconds = 10): static /** * Specify that the route should allow concurrent requests from the same session. + * + * @return $this */ public function withoutBlocking(): static { @@ -1453,6 +1506,8 @@ public function waitsFor(): ?int /** * Specify that the route should read the session without saving it. + * + * @return $this */ public function readOnlySession(bool $readOnly = true): static { @@ -1471,6 +1526,8 @@ public function hasReadOnlySession(): bool /** * Add metadata to the route. + * + * @return $this */ public function metadata(array $metadata): static { @@ -1496,6 +1553,8 @@ public function getMetadata(?string $key = null, mixed $default = null): mixed /** * Set the metadata for the route, replacing any existing metadata. + * + * @return $this */ public function setMetadata(array $metadata): static { @@ -1577,6 +1636,8 @@ public function getCompiled(): ?CompiledRoute /** * Set the router instance on the route. + * + * @return $this */ public function setRouter(Router $router): static { @@ -1587,6 +1648,8 @@ public function setRouter(Router $router): static /** * Set the container instance on the route. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/routing/src/RouteRegistrar.php b/src/routing/src/RouteRegistrar.php index 82db600b6a..592abc59ac 100644 --- a/src/routing/src/RouteRegistrar.php +++ b/src/routing/src/RouteRegistrar.php @@ -108,6 +108,8 @@ public function __construct(Router $router) /** * Set the value for a given attribute. * + * @return $this + * * @throws InvalidArgumentException */ public function attribute(string $key, mixed $value): static @@ -191,6 +193,8 @@ public function apiSingleton(string $name, string $controller, array $options = /** * Create a route group with shared attributes. + * + * @return $this */ public function group(Closure|array|string $callback): static { @@ -209,6 +213,8 @@ public function match(array|string $methods, string $uri, callable|array|string| /** * Add metadata to routes registered by the registrar. + * + * @return $this */ public function metadata(array $metadata): static { diff --git a/src/saloon/composer.json b/src/saloon/composer.json index 169ec05ff4..ce23d7da60 100644 --- a/src/saloon/composer.json +++ b/src/saloon/composer.json @@ -59,7 +59,7 @@ "psr/http-message": "^2.0", "symfony/console": "^8.1.10", "symfony/dom-crawler": "^8.1", - "symfony/filesystem": "^8.1", + "symfony/filesystem": "^8.1.11", "symfony/finder": "^8.1", "symfony/var-dumper": "^8.1" }, diff --git a/src/saloon/src/Http/Faking/MockClient.php b/src/saloon/src/Http/Faking/MockClient.php index d080712ed8..b5c0084ac8 100644 --- a/src/saloon/src/Http/Faking/MockClient.php +++ b/src/saloon/src/Http/Faking/MockClient.php @@ -246,7 +246,7 @@ public function lastPendingRequest(): ?PendingRequest */ public function lastResponse(): ?Response { - return $this->recordedResponses[array_key_last($this->recordedResponses)] ?? null; + return array_last($this->recordedResponses); } /** diff --git a/src/saloon/src/OAuthAccount.php b/src/saloon/src/OAuthAccount.php index a079de50b4..f1a614c481 100644 --- a/src/saloon/src/OAuthAccount.php +++ b/src/saloon/src/OAuthAccount.php @@ -30,8 +30,10 @@ class OAuthAccount extends Model { use HasUuids; + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'oauth_accounts'; + // @phpstan-ignore hypervel.legacyModelProperty (#[Fillable] would merge with a subclass's $fillable instead of letting it replace this list) protected array $fillable = [ 'provider', 'provider_account_id', @@ -41,6 +43,7 @@ class OAuthAccount extends Model 'scopes', ]; + // @phpstan-ignore hypervel.legacyModelProperty (#[Hidden] would merge with a subclass's $hidden instead of letting it replace this list) protected array $hidden = ['access_token', 'refresh_token']; protected array $casts = [ diff --git a/src/sanctum/composer.json b/src/sanctum/composer.json index 8d8002e86b..2bcf6c2acd 100644 --- a/src/sanctum/composer.json +++ b/src/sanctum/composer.json @@ -44,7 +44,7 @@ "hypervel/session": "^0.4", "hypervel/support": "^0.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/sanctum/src/Http/Middleware/CheckAbilities.php b/src/sanctum/src/Http/Middleware/CheckAbilities.php index dc0bb11edb..cdfaae59f3 100644 --- a/src/sanctum/src/Http/Middleware/CheckAbilities.php +++ b/src/sanctum/src/Http/Middleware/CheckAbilities.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Auth\AuthenticationException; use Hypervel\Http\Request; +use Hypervel\Sanctum\Contracts\HasApiTokens; use Hypervel\Sanctum\Exceptions\MissingAbilityException; use Symfony\Component\HttpFoundation\Response; @@ -20,6 +21,7 @@ class CheckAbilities */ public function handle(Request $request, Closure $next, string ...$abilities): Response { + /** @var ?HasApiTokens $user */ $user = $request->user(); if (! $user || ! $user->currentAccessToken()) { diff --git a/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php b/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php index 9b30ad7bd8..4ba019e981 100644 --- a/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php +++ b/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Auth\AuthenticationException; use Hypervel\Http\Request; +use Hypervel\Sanctum\Contracts\HasApiTokens; use Hypervel\Sanctum\Exceptions\MissingAbilityException; use Symfony\Component\HttpFoundation\Response; @@ -20,6 +21,7 @@ class CheckForAnyAbility */ public function handle(Request $request, Closure $next, string ...$abilities): Response { + /** @var ?HasApiTokens $user */ $user = $request->user(); if (! $user || ! $user->currentAccessToken()) { diff --git a/src/sanctum/src/PersonalAccessToken.php b/src/sanctum/src/PersonalAccessToken.php index 56b04b64e2..865c80e015 100644 --- a/src/sanctum/src/PersonalAccessToken.php +++ b/src/sanctum/src/PersonalAccessToken.php @@ -31,6 +31,7 @@ */ class PersonalAccessToken extends Model implements HasAbilities { + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'personal_access_tokens'; /** @@ -49,6 +50,7 @@ class PersonalAccessToken extends Model implements HasAbilities * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Fillable] would merge with a subclass's $fillable instead of letting it replace this list) protected array $fillable = [ 'name', 'token', @@ -61,6 +63,7 @@ class PersonalAccessToken extends Model implements HasAbilities * * @var array */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Hidden] would merge with a subclass's $hidden instead of letting it replace this list) protected array $hidden = [ 'token', ]; diff --git a/src/schema-parser/src/CheckConstraint.php b/src/schema-parser/src/CheckConstraint.php index 7438b1cdef..9fb70c25e2 100644 --- a/src/schema-parser/src/CheckConstraint.php +++ b/src/schema-parser/src/CheckConstraint.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class CheckConstraint +readonly class CheckConstraint { /** * Create a check constraint with its unevaluated SQL expression. diff --git a/src/schema-parser/src/Column.php b/src/schema-parser/src/Column.php index 40e6ced71a..2afb9d6a12 100644 --- a/src/schema-parser/src/Column.php +++ b/src/schema-parser/src/Column.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Column +readonly class Column { /** * Create a column definition. diff --git a/src/schema-parser/src/ColumnType.php b/src/schema-parser/src/ColumnType.php index b30b476708..b78406c9f1 100644 --- a/src/schema-parser/src/ColumnType.php +++ b/src/schema-parser/src/ColumnType.php @@ -9,7 +9,7 @@ * PostgreSQL standard aliases use canonical names, while quoted user types * retain their case and qualification. */ -final readonly class ColumnType +readonly class ColumnType { /** * Create a column type definition. diff --git a/src/schema-parser/src/Domain.php b/src/schema-parser/src/Domain.php index 82e248b605..9f8074109b 100644 --- a/src/schema-parser/src/Domain.php +++ b/src/schema-parser/src/Domain.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Domain +readonly class Domain { /** * Create a domain definition. diff --git a/src/schema-parser/src/EnumType.php b/src/schema-parser/src/EnumType.php index b7cd023c11..c8b22d7cfb 100644 --- a/src/schema-parser/src/EnumType.php +++ b/src/schema-parser/src/EnumType.php @@ -9,7 +9,7 @@ * * PostgreSQL enums have an identity independent of the columns using them. */ -final readonly class EnumType +readonly class EnumType { /** * Create an enum type definition. diff --git a/src/schema-parser/src/ForeignKey.php b/src/schema-parser/src/ForeignKey.php index c30f2f6298..cbe58fab71 100644 --- a/src/schema-parser/src/ForeignKey.php +++ b/src/schema-parser/src/ForeignKey.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class ForeignKey +readonly class ForeignKey { /** * Create a foreign key definition. diff --git a/src/schema-parser/src/Generation.php b/src/schema-parser/src/Generation.php index 9e2206299b..2e5538607c 100644 --- a/src/schema-parser/src/Generation.php +++ b/src/schema-parser/src/Generation.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Generation +readonly class Generation { /** * Create a generated column definition. diff --git a/src/schema-parser/src/Impact.php b/src/schema-parser/src/Impact.php new file mode 100644 index 0000000000..b6b957dfd1 --- /dev/null +++ b/src/schema-parser/src/Impact.php @@ -0,0 +1,31 @@ + $relations the tables and views the input alters, drops or renames, including both names of a rename, whether or not it also defines them + * @param bool $unknown whether a statement may have changed relations or types without naming them, such as a cascading drop, a type rename or a function call, or changed how later statements resolve names + * @param list $types the PostgreSQL enums and domains the input alters or drops without renaming them, whether or not it also defines them + */ + public function __construct( + public array $relations = [], + public bool $unknown = false, + public array $types = [], + ) { + } +} diff --git a/src/schema-parser/src/Index.php b/src/schema-parser/src/Index.php index 27eed0f1c6..d9674bc3dc 100644 --- a/src/schema-parser/src/Index.php +++ b/src/schema-parser/src/Index.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Index +readonly class Index { /** * Create an index definition. diff --git a/src/schema-parser/src/IndexColumn.php b/src/schema-parser/src/IndexColumn.php index 1cd1a212d5..db336b3013 100644 --- a/src/schema-parser/src/IndexColumn.php +++ b/src/schema-parser/src/IndexColumn.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class IndexColumn +readonly class IndexColumn { /** * Create an index key from a column name or SQL expression. diff --git a/src/schema-parser/src/Issue.php b/src/schema-parser/src/Issue.php index f6911d49cb..5d1fbdb429 100644 --- a/src/schema-parser/src/Issue.php +++ b/src/schema-parser/src/Issue.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Issue +readonly class Issue { /** * Describe uncertainty in the owning schema or table. diff --git a/src/schema-parser/src/MySqlParser.php b/src/schema-parser/src/MySqlParser.php index 6c3fb0201f..6bedec0a73 100644 --- a/src/schema-parser/src/MySqlParser.php +++ b/src/schema-parser/src/MySqlParser.php @@ -119,9 +119,13 @@ protected function parseStatement(TokenStream $tokens, ParseState $state): void } elseif ($tokens->accept('SET')) { $this->parseSet($tokens, $state); } elseif ($tokens->accept('USE')) { + // Statements parsed separately afterwards run in this database, but read as running in the original one. + $state->unknownImpact = true; $state->defaultSchema = $this->parseName($tokens, 'a database name'); - } elseif ($tokens->is('RENAME', 'TABLE')) { - $this->reportRename($state); + } elseif ($tokens->accept('RENAME', 'TABLE') || $tokens->accept('RENAME', 'TABLES')) { + $this->parseRenameTable($tokens, $state); + } else { + $this->recordUnreadStatement($tokens, $state); } } @@ -138,6 +142,11 @@ private function parseSet(TokenStream $tokens, ParseState $state): void $lexer = $tokens->lexer; $modes = [$lexer->noBackslashEscapes, $lexer->ansiQuotes]; + // MariaDB runs the statement after FOR with the given settings. + if ($tokens->is('STATEMENT')) { + $state->unknownImpact = true; + } + do { if (! $tokens->accept('SESSION')) { $tokens->accept('LOCAL'); @@ -232,6 +241,8 @@ private function parseCreate(TokenStream $tokens, ParseState $state): void $tokens->next(); $tokens->next(); $this->parseCreateIndex($tokens, $state, $replace, $kind); + } elseif (! isset(self::NON_RELATION_OBJECTS[$kind])) { + $state->unknownImpact = true; } } @@ -350,6 +361,8 @@ protected function parseTableTail(TokenStream $tokens, ParseState $state, TableD } if (! $tokens->atEnd()) { + // The query runs, and can change other relations. + $state->unknownImpact = true; $table->issues[] = $state->issue('CREATE TABLE SELECT can add columns that are not declared in the schema.'); return; @@ -894,7 +907,9 @@ private function parseAlter(TokenStream $tokens, ParseState $state): void // ALTER VIEW replaces the view's query. if ($tokens->accept('VIEW')) { - $this->parseCreateView($tokens, $state); + $state->affect($this->parseCreateView($tokens, $state)); + } elseif (! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; } } @@ -911,8 +926,17 @@ private function parseAlterTable(TokenStream $tokens, ParseState $state): void return; } + $state->affect($reference); + while (! $tokens->atEnd()) { if ($tokens->is('RENAME') && ! in_array($tokens->keyword(1), ['COLUMN', 'INDEX', 'KEY'], true)) { + $tokens->next(); + + if (! $tokens->accept('TO')) { + $tokens->accept('AS'); + } + + $state->affect($this->parseRelationName($tokens, $state, 'a new table name')); $this->reportRename($state); return; @@ -934,6 +958,31 @@ private function parseAlterTable(TokenStream $tokens, ParseState $state): void } } + /** + * Record both names of each table a RENAME TABLE statement renames. + */ + private function parseRenameTable(TokenStream $tokens, ParseState $state): void + { + // MariaDB accepts IF EXISTS and a lock wait option. + $tokens->accept('IF', 'EXISTS'); + + do { + $state->affect($this->parseRelationName($tokens, $state, 'a renamed table name')); + + if (! $tokens->accept('NOWAIT') && $tokens->accept('WAIT')) { + $tokens->next(); + } + + if (! $tokens->accept('TO')) { + throw $tokens->error('Expected the new table name'); + } + + $state->affect($this->parseRelationName($tokens, $state, 'a new table name')); + } while ($tokens->acceptSymbol(',')); + + $this->reportRename($state); + } + /** * Record that a renamed table is not replayed across the definitions that refer to it. */ @@ -957,8 +1006,12 @@ private function parseDrop(TokenStream $tokens, ParseState $state): void $view = $tokens->accept('VIEW'); - // Dropping a temporary table leaves the schema unchanged. + // Temporary tables are session objects, not part of the schema. if (! $view && ! $tokens->accept('TABLE')) { + if (! $tokens->is('TEMPORARY') && ! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; + } + return; } @@ -967,6 +1020,7 @@ private function parseDrop(TokenStream $tokens, ParseState $state): void // MySQL accepts but ignores a trailing RESTRICT or CASCADE. do { $reference = $this->parseRelationName($tokens, $state, 'a dropped object name'); + $state->affect($reference); $key = $state->key($reference->schema, $reference->name); if ($view && isset($state->views[$key])) { diff --git a/src/schema-parser/src/Parser.php b/src/schema-parser/src/Parser.php index 208f3431e2..4025f7a5cc 100644 --- a/src/schema-parser/src/Parser.php +++ b/src/schema-parser/src/Parser.php @@ -25,6 +25,23 @@ abstract class Parser implements ParserContract /** The texts of the punctuation and terminator that end a list entry. */ private const array ENTRY_ENDS = [',' => true, ')' => true, '' => true]; + /** Statements that change rows rather than relations. */ + private const array DATA_STATEMENTS = [ + 'INSERT' => true, + 'UPDATE' => true, + 'DELETE' => true, + 'REPLACE' => true, + 'TRUNCATE' => true, + ]; + + /** Kinds of objects that are not relations, which a relation only depends on in ways that stop a drop without CASCADE. */ + protected const array NON_RELATION_OBJECTS = [ + 'FUNCTION' => true, + 'PROCEDURE' => true, + 'TRIGGER' => true, + 'SEQUENCE' => true, + ]; + /** Keywords that end a column type or default by beginning the next column clause. */ protected const array COLUMN_CLAUSES = [ 'CONSTRAINT' => true, @@ -83,6 +100,18 @@ abstract protected function parseStatement(TokenStream $tokens, ParseState $stat */ abstract protected function parseType(TokenStream $tokens, ParseState $state): ColumnType; + /** + * Record that a statement the dialect does not read may change relations without naming them, unless it only changes rows. + * + * Row changes can still run server-side code, such as triggers, whose effects are outside the impact. + */ + protected function recordUnreadStatement(TokenStream $tokens, ParseState $state): void + { + if (! isset(self::DATA_STATEMENTS[$tokens->text()])) { + $state->unknownImpact = true; + } + } + /** * Read an identifier. */ @@ -472,15 +501,15 @@ protected function parseReferentialAction(TokenStream $tokens): ReferentialActio } /** - * Read a view from its name, without inferring the types its query produces. + * Read a view from its name, without inferring the types its query produces, and return its name. */ - protected function parseCreateView(TokenStream $tokens, ParseState $state, bool $materialized = false): void + protected function parseCreateView(TokenStream $tokens, ParseState $state, bool $materialized = false): TableReference { $conditional = $tokens->accept('IF', 'NOT', 'EXISTS'); $reference = $this->parseRelationName($tokens, $state, 'a view name'); if ($this->isTemporary($reference) || ($conditional && $this->relationExists($state, $reference->schema, $reference->name))) { - return; + return $reference; } // A column list and view options can precede the query. @@ -497,6 +526,8 @@ protected function parseCreateView(TokenStream $tokens, ParseState $state, bool } $state->addView(new View($reference->name, $this->parseViewQuery($tokens), $reference->schema, $materialized)); + + return $reference; } /** @@ -562,7 +593,14 @@ protected function freeze(ParseState $state): Schema $tables[] = $this->freezeTable($table, $state); } - return new Schema($tables, array_values($state->views), array_values($state->enums), array_values($state->domains), $state->issues); + return new Schema( + $tables, + array_values($state->views), + array_values($state->enums), + array_values($state->domains), + $state->issues, + new Impact(array_values($state->affectedRelations), $state->unknownImpact, array_values($state->affectedTypes)), + ); } /** diff --git a/src/schema-parser/src/Parsing/ColumnDefinition.php b/src/schema-parser/src/Parsing/ColumnDefinition.php index 4a4d84ed8a..57d15380ee 100644 --- a/src/schema-parser/src/Parsing/ColumnDefinition.php +++ b/src/schema-parser/src/Parsing/ColumnDefinition.php @@ -13,7 +13,7 @@ * * @internal */ -final class ColumnDefinition +class ColumnDefinition { public bool $nullable = true; diff --git a/src/schema-parser/src/Parsing/ParseState.php b/src/schema-parser/src/Parsing/ParseState.php index 026ea130cc..32386aa667 100644 --- a/src/schema-parser/src/Parsing/ParseState.php +++ b/src/schema-parser/src/Parsing/ParseState.php @@ -10,6 +10,7 @@ use Hypervel\SchemaParser\Index; use Hypervel\SchemaParser\Issue; use Hypervel\SchemaParser\TableReference; +use Hypervel\SchemaParser\TypeReference; use Hypervel\SchemaParser\View; /** @@ -20,7 +21,7 @@ * * @internal */ -final class ParseState +class ParseState { /** @var array */ public array $tables = []; @@ -61,6 +62,15 @@ final class ParseState /** @var list */ public array $issues = []; + /** @var array the tables and views the input alters, drops or renames, by key */ + public array $affectedRelations = []; + + /** @var array the user types and domains the input alters or drops, by key */ + public array $affectedTypes = []; + + /** Whether a statement may have changed relations or types without naming them, or changed how later statements resolve names. */ + public bool $unknownImpact = false; + /** The schema of unqualified relation names: SQLite's main database, or the one a MySQL USE statement selects. */ public ?string $defaultSchema = null; @@ -96,6 +106,26 @@ public function issue(string $message): Issue return new Issue($message, $this->line); } + /** + * Record that the input alters, drops or renames a table or view, naming an unqualified one in the default schema. + */ + public function affect(TableReference $relation): void + { + if ($relation->schema === null && $this->defaultSchema !== null) { + $relation = new TableReference($relation->name, $this->defaultSchema); + } + + $this->affectedRelations[$this->key($relation->schema, $relation->name)] ??= $relation; + } + + /** + * Record that the input alters or drops a user type or domain. + */ + public function affectType(TypeReference $type): void + { + $this->affectedTypes[$this->key($type->schema, $type->name)] ??= $type; + } + /** * Get the identity of a name, without its ASCII case where names compare case-insensitively. */ diff --git a/src/schema-parser/src/Parsing/TableDefinition.php b/src/schema-parser/src/Parsing/TableDefinition.php index 489c7d476b..6a7209e867 100644 --- a/src/schema-parser/src/Parsing/TableDefinition.php +++ b/src/schema-parser/src/Parsing/TableDefinition.php @@ -15,7 +15,7 @@ * * @internal */ -final class TableDefinition +class TableDefinition { /** @var array columns by name, in declaration order */ public array $columns = []; diff --git a/src/schema-parser/src/Parsing/TokenStream.php b/src/schema-parser/src/Parsing/TokenStream.php index 92fafbc12f..b3e19db78f 100644 --- a/src/schema-parser/src/Parsing/TokenStream.php +++ b/src/schema-parser/src/Parsing/TokenStream.php @@ -18,7 +18,7 @@ * * @internal */ -final class TokenStream +class TokenStream { /** Keyword operators that take an operand after them, in the expressions that clause keywords can end. */ private const array OPERATORS = [ diff --git a/src/schema-parser/src/PostgresParser.php b/src/schema-parser/src/PostgresParser.php index 6e306c84d8..48e2141749 100644 --- a/src/schema-parser/src/PostgresParser.php +++ b/src/schema-parser/src/PostgresParser.php @@ -135,6 +135,26 @@ protected function parseStatement(TokenStream $tokens, ParseState $state): void $this->parseDrop($tokens, $state); } elseif ($tokens->accept('SET', 'STANDARD_CONFORMING_STRINGS') || $tokens->accept('SET', 'SESSION', 'STANDARD_CONFORMING_STRINGS')) { $this->parseStandardConformingStrings($tokens); + } elseif ($tokens->accept('SET')) { + $this->parseSet($tokens, $state); + } else { + $this->recordUnreadStatement($tokens, $state); + } + } + + /** + * Record a setting that changes how later statements resolve names, which other settings leave unchanged. + */ + private function parseSet(TokenStream $tokens, ParseState $state): void + { + if (! $tokens->accept('SESSION')) { + $tokens->accept('LOCAL'); + } + + // The role is the search path's $user schema. + if ($tokens->is('SEARCH_PATH') || $tokens->is('SCHEMA') || $tokens->is('ROLE') + || $tokens->is('AUTHORIZATION') || $tokens->is('SESSION', 'AUTHORIZATION')) { + $state->unknownImpact = true; } } @@ -201,6 +221,8 @@ private function parseCreate(TokenStream $tokens, ParseState $state): void $this->parseCreateEnum($tokens, $state); } elseif ($tokens->accept('DOMAIN')) { $this->parseCreateDomain($tokens, $state); + } elseif (! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; } } @@ -233,7 +255,7 @@ private function parseCreateTable(TokenStream $tokens, ParseState $state, bool $ } $table->issues[] = $state->issue('The partition declaration does not include its complete column and constraint definitions.'); - } elseif ($tokens->isSymbol('(')) { + } elseif ($tokens->isSymbol('(') && ! $this->isQueryColumnList($tokens)) { $this->parseTableBody($tokens, $state, $table); if ($tokens->accept('INHERITS')) { @@ -263,6 +285,19 @@ private function parseCreateTable(TokenStream $tokens, ParseState $state, bool $ } elseif ($tokens->atEnd()) { throw $tokens->error('Expected a table definition'); } else { + while (! $tokens->atEnd() && ! $tokens->is('AS')) { + if ($tokens->isSymbol('(')) { + $tokens->skipGroup(); + } else { + $tokens->next(); + } + } + + // CREATE TABLE AS runs its query, which can change other relations. + if (! $tokens->atEnd()) { + $state->unknownImpact = true; + } + $table->issues[] = $state->issue('The table declaration does not contain a complete column definition.'); } @@ -277,6 +312,17 @@ private function parseCreateTable(TokenStream $tokens, ParseState $state, bool $ } } + /** + * Determine whether a parenthesized list names the columns of a CREATE TABLE AS query rather than declaring them. + */ + private function isQueryColumnList(TokenStream $tokens): bool + { + $name = $tokens->type(1); + + return ($name === TokenType::Word || $name === TokenType::QuotedIdentifier) + && ($tokens->isSymbol(',', 2) || $tokens->isSymbol(')', 2)); + } + /** * Determine whether a name belongs to the session's temporary schema. */ @@ -1256,15 +1302,30 @@ private function parseAlter(TokenStream $tokens, ParseState $state): void { if ($tokens->accept('TABLE') || $tokens->accept('FOREIGN', 'TABLE')) { $this->parseAlterTable($tokens, $state); + } elseif ($tokens->accept('VIEW') || $tokens->accept('MATERIALIZED', 'VIEW')) { + $this->parseAlterView($tokens, $state); } elseif ($tokens->accept('DOMAIN')) { $this->parseAlterDomain($tokens, $state); } elseif ($tokens->accept('TYPE')) { - $this->parseQualifiedName($tokens, 'a type name'); + $reference = $this->parseQualifiedName($tokens, 'a type name'); - if ($this->isIdentityChange($tokens) || $tokens->is('ADD', 'VALUE') || $tokens->is('RENAME', 'VALUE')) { + if ($tokens->is('ADD', 'VALUE') || $tokens->is('RENAME', 'VALUE')) { + // Only the enum's labels change; its columns keep referring to it by name. + $state->affectType(new TypeReference($reference->name, $reference->schema)); $this->reportIdentityChange($state); + } else { + // A rename or schema move reaches the type's columns without naming them, as other changes can. + $state->unknownImpact = true; + + if ($this->isIdentityChange($tokens)) { + $this->reportIdentityChange($state); + } } } else { + if (! $tokens->is('INDEX') && ! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; + } + while (! $tokens->atEnd()) { if ($this->isIdentityChange($tokens)) { $this->reportIdentityChange($state); @@ -1296,6 +1357,22 @@ private function isIdentityChange(TokenStream $tokens): bool && $tokens->type(3) === TokenType::End; } + /** + * Read the new identity that a rename or schema change gives a relation. + */ + private function parseIdentityChange(TokenStream $tokens, TableReference $reference): TableReference + { + $rename = $tokens->accept('RENAME', 'TO'); + + if (! $rename) { + $tokens->accept('SET', 'SCHEMA'); + } + + $name = $this->parseName($tokens, 'a new identity'); + + return $rename ? new TableReference($name, $reference->schema) : new TableReference($reference->name, $name); + } + /** * Record that an identity or enum label change is not replayed across dependent definitions. */ @@ -1311,6 +1388,8 @@ private function reportIdentityChange(ParseState $state): void */ private function reportCascade(ParseState $state): void { + $state->unknownImpact = true; + if ($state->hasDefinitions()) { $state->issues[] = $state->issue('Cascading drops can change dependent definitions that are not replayed.'); } @@ -1325,8 +1404,10 @@ private function parseAlterTable(TokenStream $tokens, ParseState $state): void $only = $tokens->accept('ONLY'); $reference = $this->parseQualifiedName($tokens, 'a table name'); $tokens->acceptSymbol('*'); + $state->affect($reference); if ($this->isIdentityChange($tokens)) { + $state->affect($this->parseIdentityChange($tokens, $reference)); $this->reportIdentityChange($state); return; @@ -1340,6 +1421,21 @@ private function parseAlterTable(TokenStream $tokens, ParseState $state): void } while ($tokens->acceptSymbol(',')); } + /** + * Record the view or materialized view an ALTER statement changes, and its new identity after a rename. + */ + private function parseAlterView(TokenStream $tokens, ParseState $state): void + { + $tokens->accept('IF', 'EXISTS'); + $reference = $this->parseQualifiedName($tokens, 'a view name'); + $state->affect($reference); + + if ($this->isIdentityChange($tokens)) { + $state->affect($this->parseIdentityChange($tokens, $reference)); + $this->reportIdentityChange($state); + } + } + /** * Apply one ALTER TABLE action, or record what it leaves unknown. */ @@ -1349,9 +1445,12 @@ private function parseAlterTableAction(TokenStream $tokens, ParseState $state, T // A clean dump drops constraints before it creates their tables. $constraint = $tokens->keyword(1) === 'CONSTRAINT'; + // A cascade affects other definitions; the dropped target is still not applied. if ($this->endsWithCascade($tokens)) { $this->reportCascade($state); - } elseif ($key !== null) { + } + + if ($key !== null) { $state->tables[$key]->issues[] = $state->issue('An ALTER TABLE action could not be represented.'); } elseif (! $view && ! $constraint) { $state->issues[] = $state->issue("The altered table [{$reference->name}] could not be resolved."); @@ -1834,11 +1933,16 @@ private function parseAlterDomain(TokenStream $tokens, ParseState $state): void $reference = $this->parseQualifiedName($tokens, 'a domain name'); if ($this->isIdentityChange($tokens)) { + // The domain's columns follow the rename or schema move without being named. + $state->unknownImpact = true; $this->reportIdentityChange($state); return; } + // ALTER DOMAIN cannot change the base type, so the named domain bounds the change. + $state->affectType(new TypeReference($reference->name, $reference->schema)); + if ($tokens->is('OWNER', 'TO')) { return; } @@ -1968,12 +2072,16 @@ private function parseDrop(TokenStream $tokens, ParseState $state): void }; if ($kind === null) { + $nonRelation = isset(self::NON_RELATION_OBJECTS[$tokens->text()]); + do { $cascade = $this->endsWithCascade($tokens); } while ($tokens->acceptSymbol(',')); if ($cascade) { $this->reportCascade($state); + } elseif (! $nonRelation) { + $state->unknownImpact = true; } return; @@ -2018,6 +2126,12 @@ private function drop(string $kind, TableReference $reference, bool $cascade, Pa return; } + if ($kind === 'table' || $kind === 'view') { + $state->affect($reference); + } elseif ($kind === 'type' || $kind === 'domain') { + $state->affectType(new TypeReference($reference->name, $reference->schema)); + } + if ($kind === 'schema') { $name = $reference->name; diff --git a/src/schema-parser/src/SQLiteParser.php b/src/schema-parser/src/SQLiteParser.php index ad22accea7..725e570ce4 100644 --- a/src/schema-parser/src/SQLiteParser.php +++ b/src/schema-parser/src/SQLiteParser.php @@ -80,6 +80,13 @@ protected function parseStatement(TokenStream $tokens, ParseState $state): void $this->parseDrop($tokens, $state); } elseif ($tokens->accept('ALTER', 'TABLE')) { $this->parseAlterTable($tokens, $state); + } elseif ($tokens->accept('PRAGMA')) { + // It lets later statements write the schema table, which changes relations without naming them. + if (strcasecmp($this->parseQualifiedName($tokens, 'a pragma name')->name, 'writable_schema') === 0) { + $state->unknownImpact = true; + } + } else { + $this->recordUnreadStatement($tokens, $state); } } @@ -100,6 +107,8 @@ private function parseCreate(TokenStream $tokens, ParseState $state): void $this->parseCreateIndex($tokens, $state, true); } elseif ($tokens->accept('VIRTUAL', 'TABLE')) { $this->parseCreateVirtualTable($tokens, $state); + } elseif (! $tokens->is('TEMP') && ! $tokens->is('TEMPORARY') && ! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; } } @@ -162,6 +171,8 @@ private function parseCreateTable(TokenStream $tokens, ParseState $state): void $table = new TableDefinition($reference->name, $reference->schema); if ($tokens->accept('AS')) { + // The query runs, and can change other relations. + $state->unknownImpact = true; $table->issues[] = $state->issue('The table declaration does not contain a complete column definition.'); } else { $this->parseTableBody($tokens, $state, $table); @@ -809,8 +820,13 @@ private function parseAlterTable(TokenStream $tokens, ParseState $state): void return; } + $state->affect($reference); + // A renamed table changes the relation names that other definitions and lookups rely on. - if ($tokens->is('RENAME', 'TO')) { + if ($tokens->accept('RENAME', 'TO')) { + // The table stays in its database. + $state->affect(new TableReference($this->parseName($tokens, 'a new table name'), $reference->schema)); + if ($state->hasDefinitions()) { $state->issues[] = $state->issue('A table rename changes table identities that are not replayed.'); } @@ -840,6 +856,10 @@ private function parseDrop(TokenStream $tokens, ParseState $state): void }; if ($kind === null) { + if (! isset(self::NON_RELATION_OBJECTS[$tokens->text()])) { + $state->unknownImpact = true; + } + return; } @@ -856,6 +876,7 @@ private function parseDrop(TokenStream $tokens, ParseState $state): void return; } + $state->affect($reference); $view = $kind === 'view'; $key = $this->relationKey($state, $reference, $view); diff --git a/src/schema-parser/src/Schema.php b/src/schema-parser/src/Schema.php index e19b5edef4..52ea9660d5 100644 --- a/src/schema-parser/src/Schema.php +++ b/src/schema-parser/src/Schema.php @@ -6,7 +6,7 @@ use InvalidArgumentException; -final readonly class Schema +readonly class Schema { /** @var array> */ private array $tablesByName; @@ -30,6 +30,7 @@ * @param list $enums * @param list $domains * @param list $issues + * @param Impact $impact how the input affects relations that existed before it */ public function __construct( public array $tables = [], @@ -37,6 +38,7 @@ public function __construct( public array $enums = [], public array $domains = [], public array $issues = [], + public Impact $impact = new Impact, ) { $this->tablesByName = $this->indexDefinitions($tables); $this->viewsByName = $this->indexDefinitions($views); @@ -130,6 +132,6 @@ private function findDefinition(array $index, string $name, ?string $schema): Ta throw new InvalidArgumentException("The name '{$name}' is ambiguous across schemas: " . implode(', ', $schemas) . '.'); } - return $matches[array_key_first($matches)] ?? null; + return $matches === [] ? null : $matches[array_key_first($matches)]; } } diff --git a/src/schema-parser/src/Table.php b/src/schema-parser/src/Table.php index 1019946a7b..8c1766ff66 100644 --- a/src/schema-parser/src/Table.php +++ b/src/schema-parser/src/Table.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class Table +readonly class Table { /** @var array */ private array $columnsByName; diff --git a/src/schema-parser/src/TableReference.php b/src/schema-parser/src/TableReference.php index 1bb0501d22..7ba497bb18 100644 --- a/src/schema-parser/src/TableReference.php +++ b/src/schema-parser/src/TableReference.php @@ -4,7 +4,7 @@ namespace Hypervel\SchemaParser; -final readonly class TableReference +readonly class TableReference { /** * Create a table reference without assuming an unspecified schema. diff --git a/src/schema-parser/src/TypeReference.php b/src/schema-parser/src/TypeReference.php new file mode 100644 index 0000000000..463da7bf98 --- /dev/null +++ b/src/schema-parser/src/TypeReference.php @@ -0,0 +1,17 @@ +finished($operation, $token, $operationException); } catch (Throwable $throwable) { - if (Coroutine::causedByCancellation($throwable)) { - throw $throwable; + // An observer that rethrows the operation's failure adds none; it is thrown below. + if ($throwable !== $operationException) { + $observerException = FailureReporter::preferCancellation($observerException, $throwable); } - - $observerException ??= $throwable; } } - if ($operationException !== null) { - throw $operationException; - } + $exception = $observerException === null + ? $operationException + : FailureReporter::preferCancellation($operationException, $observerException); - if ($observerException !== null) { - throw $observerException; + if ($exception !== null) { + throw $exception; } return $result; diff --git a/src/scout/src/Engines/MeilisearchEngine.php b/src/scout/src/Engines/MeilisearchEngine.php index 4378f32557..c268d1d4af 100644 --- a/src/scout/src/Engines/MeilisearchEngine.php +++ b/src/scout/src/Engines/MeilisearchEngine.php @@ -394,7 +394,7 @@ protected function buildSortFromOrderByClauses(Builder $builder): array { return collect($builder->orders) ->map(fn (array $order) => $order['column'] . ':' . $order['direction']) - ->toArray(); + ->all(); } /** diff --git a/src/scout/src/Jobs/MakeRangeSearchable.php b/src/scout/src/Jobs/MakeRangeSearchable.php index 1cf568baeb..b6664881ac 100644 --- a/src/scout/src/Jobs/MakeRangeSearchable.php +++ b/src/scout/src/Jobs/MakeRangeSearchable.php @@ -18,6 +18,7 @@ class MakeRangeSearchable implements ShouldQueue /** * Indicates if the job should be marked as failed on timeout. */ + // @phpstan-ignore hypervel.legacyQueueProperty (public property; removing it would leave assignments to it without declared storage) public bool $failOnTimeout = true; /** diff --git a/src/scout/src/SearchableScope.php b/src/scout/src/SearchableScope.php index de47480070..ca1b395759 100644 --- a/src/scout/src/SearchableScope.php +++ b/src/scout/src/SearchableScope.php @@ -49,7 +49,6 @@ public function extend(EloquentBuilder $builder): void /* @phpstan-ignore method.notFound (searchable() macro is registered by the Searchable trait) */ $searchableModels->searchable(); - // @phpstan-ignore staticMethod.notFound (local macros retain their lexical class scope at runtime) static::dispatchEvent(ModelsImported::class, $models); Scout::reportImportProgress($models); }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); @@ -66,7 +65,6 @@ public function extend(EloquentBuilder $builder): void /* @phpstan-ignore method.notFound (unsearchable() macro is registered by the Searchable trait) */ $models->unsearchable(); - // @phpstan-ignore staticMethod.notFound (local macros retain their lexical class scope at runtime) static::dispatchEvent(ModelsFlushed::class, $models); }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); }); diff --git a/src/scout/src/Traits/ConfiguresJobOptions.php b/src/scout/src/Traits/ConfiguresJobOptions.php index 1634b1b00d..76f8573e86 100644 --- a/src/scout/src/Traits/ConfiguresJobOptions.php +++ b/src/scout/src/Traits/ConfiguresJobOptions.php @@ -30,6 +30,7 @@ trait ConfiguresJobOptions /** * Indicates if the job should be marked as failed on timeout. */ + // @phpstan-ignore hypervel.legacyQueueProperty (public property; removing it would leave assignments to it without declared storage) public bool $failOnTimeout = true; /** diff --git a/src/scout/src/Traits/UniqueByScoutKeys.php b/src/scout/src/Traits/UniqueByScoutKeys.php index e467a63121..064f7df47f 100644 --- a/src/scout/src/Traits/UniqueByScoutKeys.php +++ b/src/scout/src/Traits/UniqueByScoutKeys.php @@ -14,6 +14,7 @@ trait UniqueByScoutKeys * * @var int */ + // @phpstan-ignore hypervel.legacyQueueProperty (public property; removing it would leave assignments to it without declared storage) public $uniqueFor = 3600; /** diff --git a/src/sentinel/composer.json b/src/sentinel/composer.json index 345fe84781..61c73406b0 100644 --- a/src/sentinel/composer.json +++ b/src/sentinel/composer.json @@ -31,7 +31,7 @@ "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", "hypervel/support": "^0.4", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/sentinel/src/SentinelManager.php b/src/sentinel/src/SentinelManager.php index 9971ade27a..48471084da 100644 --- a/src/sentinel/src/SentinelManager.php +++ b/src/sentinel/src/SentinelManager.php @@ -8,7 +8,6 @@ use Hypervel\Sentinel\Drivers\Driver; use Hypervel\Sentinel\Drivers\Hypervel; use Hypervel\Support\Manager; -use Hypervel\Support\Str; use InvalidArgumentException; class SentinelManager extends Manager @@ -42,7 +41,7 @@ public function driverOrFallback(?string $driver): Driver { if ($driver !== null && ! isset($this->customCreators[$driver]) - && ! method_exists($this, 'create' . Str::studly($driver) . 'Driver')) { + && $this->getCreatorMethod($driver) === null) { $driver = null; } diff --git a/src/sentry/composer.json b/src/sentry/composer.json index 00ca5de71e..6e68c3c2fd 100644 --- a/src/sentry/composer.json +++ b/src/sentry/composer.json @@ -50,6 +50,7 @@ "hypervel/guzzle": "^8.3", "hypervel/guzzle-promises": "^3.1", "hypervel/http": "^0.4", + "hypervel/http-server": "^0.4", "hypervel/log": "^0.4", "hypervel/notifications": "^0.4", "hypervel/object-pool": "^0.4", @@ -64,7 +65,7 @@ "psr/log": "^3.0", "sentry/sentry": "^4.34", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "vlucas/phpdotenv": "^5.6.1" }, "config": { diff --git a/src/sentry/src/Features/Concerns/WorksWithSpans.php b/src/sentry/src/Features/Concerns/WorksWithSpans.php index 44f418b1bc..077ea35edd 100644 --- a/src/sentry/src/Features/Concerns/WorksWithSpans.php +++ b/src/sentry/src/Features/Concerns/WorksWithSpans.php @@ -6,6 +6,9 @@ use Sentry\SentrySdk; use Sentry\Tracing\Span; +use Sentry\Tracing\SpanStatus; +use Swoole\Coroutine\CanceledException; +use Throwable; trait WorksWithSpans { @@ -39,4 +42,19 @@ protected function withParentSpanIfSampled(callable $callback): void $callback($parentSpan); } + + /** + * Get the status of an operation that cancellation stopped. + * + * A direct cancellation with no earlier failure leaves the status unset: it is + * control flow, and the SDK has no canceled status. A known failure, or a + * cleanup failure carrying the cancellation, is an error. + */ + protected function cancellationStatus(Throwable $cancellation, ?Throwable $exception = null): ?SpanStatus + { + // @phpstan-ignore hypervel.cancellation.exactType (only a direct cancellation is not a failure; a cleanup failure carrying it is recorded as one) + return $exception === null && $cancellation instanceof CanceledException + ? null + : SpanStatus::internalError(); + } } diff --git a/src/sentry/src/Features/ConsoleSchedulingIntegration.php b/src/sentry/src/Features/ConsoleSchedulingIntegration.php index ca709c2575..085a1ee9b7 100644 --- a/src/sentry/src/Features/ConsoleSchedulingIntegration.php +++ b/src/sentry/src/Features/ConsoleSchedulingIntegration.php @@ -6,12 +6,14 @@ use DateTimeZone; use Hypervel\Console\Application as ConsoleApplication; +use Hypervel\Console\Events\ScheduledTaskCanceled; use Hypervel\Console\Events\ScheduledTaskFailed; use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Console\Events\ScheduledTaskStarting; use Hypervel\Console\Scheduling\Event as SchedulingEvent; use Hypervel\Context\CoroutineContext; use Hypervel\Sentry\Features\Concerns\TracksPushedScopesAndSpans; +use Hypervel\Sentry\Features\Concerns\WorksWithSpans; use Hypervel\Support\Str; use RuntimeException; use Sentry\CheckIn; @@ -27,6 +29,7 @@ class ConsoleSchedulingIntegration extends Feature { use TracksPushedScopesAndSpans; + use WorksWithSpans; /** * The context key for the check-ins in progress in the current coroutine. @@ -130,15 +133,18 @@ public function onBoot(): void { $this->shouldHandleCheckIn = true; + $dispatcher = $this->container->make('events'); + + // A failed or canceled run can skip the after callbacks that finish its check-in. + $dispatcher->listen(ScheduledTaskFailed::class, [$this, 'handleScheduledTaskFailed']); + $dispatcher->listen(ScheduledTaskCanceled::class, [$this, 'handleScheduledTaskCanceled']); + if (! $this->canRecordSpans()) { return; } - $dispatcher = $this->container->make('events'); - $dispatcher->listen(ScheduledTaskStarting::class, [$this, 'handleScheduledTaskStarting']); $dispatcher->listen(ScheduledTaskFinished::class, [$this, 'handleScheduledTaskFinished']); - $dispatcher->listen(ScheduledTaskFailed::class, [$this, 'handleScheduledTaskFailed']); } /** @@ -177,20 +183,50 @@ public function handleScheduledTaskStarting(ScheduledTaskStarting $event): void */ public function handleScheduledTaskFinished(ScheduledTaskFinished $event): void { - $exitCode = $event->task->exitCode(); - $status = $exitCode === null || $exitCode === 0 - ? SpanStatus::ok() - : SpanStatus::internalError(); - - $this->maybeFinishSpan($status); + $this->maybeFinishSpan($this->finishedStatus($event->task)); } /** - * Mark tracing for the scheduled task as failed. + * Mark tracing and the unfinished check-ins of the scheduled task as failed. */ public function handleScheduledTaskFailed(): void { $this->maybeFinishSpan(SpanStatus::internalError()); + $this->failUnfinishedCheckIns(); + } + + /** + * Finish tracing the scheduled task that cancellation stopped before its outcome was published. + * + * A direct cancellation leaves an unfinished check-in without a terminal status: the SDK has no + * canceled status, and the cancellation is not a task failure. + */ + public function handleScheduledTaskCanceled(ScheduledTaskCanceled $event): void + { + $status = match (true) { + $event->exception !== null => SpanStatus::internalError(), + // A task that finished running keeps its outcome; the cancellation came from a listener. + $event->runtime !== null => $this->finishedStatus($event->task), + default => $this->cancellationStatus($event->cancellation), + }; + + $this->maybeFinishSpan($status); + + if ($status === SpanStatus::internalError()) { + $this->failUnfinishedCheckIns(); + } + } + + /** + * Get the span status of a scheduled task that finished running. + */ + private function finishedStatus(SchedulingEvent $task): SpanStatus + { + $exitCode = $task->exitCode(); + + return $exitCode === null || $exitCode === 0 + ? SpanStatus::ok() + : SpanStatus::internalError(); } /** @@ -269,6 +305,38 @@ private function finishCheckIn(?string $slug, SchedulingEvent $scheduled, CheckI unset($checkIns[$cacheKey]); CoroutineContext::set(self::CHECK_INS_CONTEXT_KEY, $checkIns); + $this->sendFinalCheckIn($checkIn, $status); + } + + /** + * Send the error check-in for every check-in the task's coroutine started and has not finished. + * + * Each task runs in its own coroutine, so the stored check-ins belong to the failed task. + */ + private function failUnfinishedCheckIns(): void + { + if (! $this->shouldHandleCheckIn) { + return; + } + + $checkIns = CoroutineContext::get(self::CHECK_INS_CONTEXT_KEY, []); + + if ($checkIns === []) { + return; + } + + CoroutineContext::forget(self::CHECK_INS_CONTEXT_KEY); + + foreach ($checkIns as $checkIn) { + $this->sendFinalCheckIn($checkIn, CheckInStatus::error()); + } + } + + /** + * Send a started check-in's terminal status. + */ + private function sendFinalCheckIn(CheckIn $checkIn, CheckInStatus $status): void + { // The start check-in may still be waiting for asynchronous delivery, so it must not change $checkIn = clone $checkIn; $checkIn->setStatus($status); diff --git a/src/sentry/src/Features/QueueIntegration.php b/src/sentry/src/Features/QueueIntegration.php index aa0101f85a..b988556e42 100644 --- a/src/sentry/src/Features/QueueIntegration.php +++ b/src/sentry/src/Features/QueueIntegration.php @@ -14,11 +14,13 @@ use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; +use Hypervel\Queue\Events\JobQueueingCanceled; use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Queue\Events\WorkerStopping; use Hypervel\Queue\Queue; use Hypervel\Queue\WorkerStopReason; use Hypervel\Sentry\Features\Concerns\TracksPushedScopesAndSpans; +use Hypervel\Sentry\Features\Concerns\WorksWithSpans; use Hypervel\Sentry\Integration; use Hypervel\Support\Str; use Sentry\Breadcrumb; @@ -45,6 +47,7 @@ class QueueIntegration extends Feature use TracksPushedScopesAndSpans { pushScope as private pushScopeTrait; } + use WorksWithSpans; private const string QUEUE_SPAN_OP_QUEUE_PUBLISH = 'queue.publish'; @@ -82,6 +85,7 @@ public function onBoot(): void $dispatcher->listen(JobQueueing::class, [$this, 'handleJobQueueingEvent']); $dispatcher->listen(JobQueued::class, [$this, 'handleJobQueuedEvent']); $dispatcher->listen(JobQueueingFailed::class, [$this, 'handleJobQueueingFailedEvent']); + $dispatcher->listen(JobQueueingCanceled::class, [$this, 'handleJobQueueingCanceledEvent']); } if ($recordSpans || $recordBreadcrumbs) { @@ -167,6 +171,18 @@ public function handleJobQueueingFailedEvent(JobQueueingFailed $event): void $this->maybeFinishLocalSpan($event->payload, SpanStatus::internalError()); } + /** + * Finish the publish span of the job whose queueing was canceled. + */ + public function handleJobQueueingCanceledEvent(JobQueueingCanceled $event): void + { + // A queued job keeps its success; the cancellation came from a queued listener. + $this->maybeFinishLocalSpan( + $event->payload, + $event->queued ? SpanStatus::ok() : $this->cancellationStatus($event->cancellation, $event->exception), + ); + } + /** * Finish the process span and pop the scope of the processed job. */ @@ -304,7 +320,8 @@ public function handleJobFailedEvent(JobFailed $event): void * Mark the job attempt as ended. * * A failed job can keep running and dispatch other jobs, so a retained - * scope is only discarded once its attempt has ended. + * scope is only discarded once its attempt has ended. A canceled attempt + * skipped the processed and failed events, so its span finishes here. */ public function handleJobAttemptedEvent(JobAttempted $event): void { @@ -316,7 +333,17 @@ public function handleJobAttemptedEvent(JobAttempted $event): void } $frames[$index]['ended'] = true; + $finishSpan = $event->canceled && $frames[$index]['span'] !== null; + + if ($finishSpan) { + $frames[$index]['span'] = null; + } + CoroutineContext::set(self::JOB_FRAMES_CONTEXT_KEY, $frames); + + if ($finishSpan) { + $this->maybeFinishSpan($event->exception === null ? null : $this->cancellationStatus($event->exception)); + } } /** diff --git a/src/sentry/src/Hub.php b/src/sentry/src/Hub.php index 976e5393c2..d0b89bbae4 100644 --- a/src/sentry/src/Hub.php +++ b/src/sentry/src/Hub.php @@ -457,6 +457,12 @@ public function getSpan(): ?Span /** * Run the callback with a new scope that is removed afterwards. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template T + * + * @param callable(Scope): T $callback + * @return T */ public function withScope(callable $callback): mixed { @@ -506,6 +512,12 @@ public function captureCheckIn( /** * Get the client's integration with the given class name. + * + * @TODO Remove repeated generic tags once PHPStan correctly inherits method templates. + * @template T of IntegrationInterface + * + * @param class-string $className + * @return null|T */ public function getIntegration(string $className): ?IntegrationInterface { diff --git a/src/sentry/src/SentryServiceProvider.php b/src/sentry/src/SentryServiceProvider.php index 4b5c408fc4..0c5d72c04b 100644 --- a/src/sentry/src/SentryServiceProvider.php +++ b/src/sentry/src/SentryServiceProvider.php @@ -7,6 +7,7 @@ use ArrayObject; use Hypervel\Config\Repository as ConfigRepository; use Hypervel\Console\Events\ScheduledTaskStarting; +use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Events\Dispatcher; @@ -18,6 +19,7 @@ use Hypervel\Di\Bootstrap\GenerateProxies; use Hypervel\Foundation\Console\AboutCommand; use Hypervel\Http\Request; +use Hypervel\HttpServer\Events\RequestCanceled; use Hypervel\ObjectPool\PoolOptions; use Hypervel\Queue\Events\JobProcessing; use Hypervel\Routing\Contracts\CallableDispatcher; @@ -564,6 +566,15 @@ private function bindTracingEvents(array $tracingConfig): void $dispatcher = $this->app->make('events'); $handler->subscribe($dispatcher); + + // Termination completes the request transaction, and cancellation can stop a request before it. + $dispatcher->listen(RequestCanceled::class, static function (RequestCanceled $event): void { + $container = Container::getInstance(); + + if ($container->resolvedScoped(TracingMiddleware::class)) { + $container->make(TracingMiddleware::class)->requestCanceled($event); + } + }); } /** diff --git a/src/sentry/src/Tracing/Middleware.php b/src/sentry/src/Tracing/Middleware.php index c433549171..ad44b84990 100644 --- a/src/sentry/src/Tracing/Middleware.php +++ b/src/sentry/src/Tracing/Middleware.php @@ -7,6 +7,8 @@ use Closure; use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; +use Hypervel\HttpServer\Events\RequestCanceled; +use Hypervel\Sentry\Features\Concerns\WorksWithSpans; use Hypervel\Sentry\Http\HypervelRequestFetcher; use Hypervel\Sentry\Http\SensitiveCookieFilter; use Sentry\DataCollection\DataCollectionPolicy; @@ -27,6 +29,8 @@ */ class Middleware { + use WorksWithSpans; + /** * The current active transaction. */ @@ -96,6 +100,39 @@ public function terminate(Request $request, Response $response): void } } + /** + * Complete the transaction when cancellation stopped the request before it terminated. + */ + public function requestCanceled(RequestCanceled $event): void + { + if ($this->transaction === null) { + return; + } + + if ($this->shouldRouteBeIgnored()) { + $this->discardTransaction(); + + return; + } + + if ($this->appSpan !== null) { + $this->appSpan->finish(); + $this->appSpan = null; + } + + $this->transaction->setData($this->collectRequestData()); + + if ($event->response !== null) { + $this->hydrateResponseData($event->response); + } elseif (($status = $this->cancellationStatus($event->cancellation, $event->exception)) !== null) { + $this->transaction->setStatus($status); + } + + if (! $this->continueAfterResponse) { + $this->finishTransaction(); + } + } + /** * Start the transaction for the incoming request. */ diff --git a/src/sentry/src/Transport/HttpPoolTransport.php b/src/sentry/src/Transport/HttpPoolTransport.php index 800ba1404a..e254d1c42c 100644 --- a/src/sentry/src/Transport/HttpPoolTransport.php +++ b/src/sentry/src/Transport/HttpPoolTransport.php @@ -16,7 +16,6 @@ use Sentry\Transport\Result; use Sentry\Transport\ResultStatus; use Sentry\Transport\TransportInterface; -use Swoole\Coroutine\CanceledException; use Swoole\Runtime; use Throwable; @@ -101,7 +100,7 @@ public function send(Event $event): Result } } - if ($exception instanceof CanceledException) { + if (Coroutine::causedByCancellation($exception)) { throw $exception; } diff --git a/src/server-process/src/AbstractProcess.php b/src/server-process/src/AbstractProcess.php index 24f86591c7..ce85cc73d8 100644 --- a/src/server-process/src/AbstractProcess.php +++ b/src/server-process/src/AbstractProcess.php @@ -94,33 +94,33 @@ public function bind(Server $server): void $this->event->dispatch(new AfterProcessHandle($this, $i)); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if (isset($quit)) { try { $quit->push(true); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } try { Timer::clearAll(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } try { CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } try { Sleep::sleep($this->restartInterval); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/server/src/Server.php b/src/server/src/Server.php index e2fd0c33dd..7fdb519013 100644 --- a/src/server/src/Server.php +++ b/src/server/src/Server.php @@ -277,6 +277,7 @@ protected function guardResponseCallback(callable $callback, bool $secure): Clos $callback($request, $response); } catch (Throwable $throwable) { + // @phpstan-ignore hypervel.cancellation.exactType (a direct cancellation is never reported; a failure carrying it is reported) if (! $throwable instanceof CanceledException) { $this->reportCallbackFailure($throwable); } diff --git a/src/session/composer.json b/src/session/composer.json index 9aebda387b..86236e8d82 100644 --- a/src/session/composer.json +++ b/src/session/composer.json @@ -46,7 +46,7 @@ "hypervel/support": "^0.4", "symfony/console": "^8.1.10", "symfony/finder": "^8.1", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "suggest": { "ext-redis": "Required to use the Redis session driver (^6.1); user session tracking requires ^6.3." diff --git a/src/session/src/DatabaseSessionHandler.php b/src/session/src/DatabaseSessionHandler.php index 61da5f9fb5..b43c6d8703 100644 --- a/src/session/src/DatabaseSessionHandler.php +++ b/src/session/src/DatabaseSessionHandler.php @@ -199,6 +199,8 @@ protected function getDefaultPayload(string $sessionId, string $data): array /** * Add the request information to the session payload. + * + * @return $this */ protected function addRequestInformation(array &$payload): static { @@ -355,6 +357,8 @@ public function connection(): ConnectionInterface * Boot or tests only. Mutating the container on a shared handler during * request handling can expose the wrong request or authentication state * to concurrent coroutines. + * + * @return $this */ public function setContainer(Container $container): static { @@ -365,6 +369,8 @@ public function setContainer(Container $container): static /** * Set the existence state for the session. + * + * @return $this */ public function setExists(bool $value): static { @@ -395,6 +401,8 @@ protected function existenceState(): ?bool /** * Set whether the current session record is expired. + * + * @return $this */ protected function setExpired(bool $value): static { diff --git a/src/session/src/Middleware/AuthenticateSession.php b/src/session/src/Middleware/AuthenticateSession.php index 29fd6b072c..866aeae4d8 100644 --- a/src/session/src/Middleware/AuthenticateSession.php +++ b/src/session/src/Middleware/AuthenticateSession.php @@ -38,7 +38,7 @@ public function handle(Request $request, Closure $next): mixed } if ($this->guard()->viaRemember()) { // @phpstan-ignore method.notFound (proxied to SessionGuard via AuthManager::__call) - $passwordHashFromCookie = explode('|', $request->cookies->get($this->guard()->getRecallerName()))[2] ?? null; // @phpstan-ignore method.notFound + $passwordHashFromCookie = explode('|', $request->cookies->get($this->guard()->getRecallerName()))[2] ?? null; // @phpstan-ignore method.notFound (proxied to SessionGuard via AuthManager::__call) if (! $passwordHashFromCookie || ! $this->validatePasswordHash($request->user()->getAuthPassword(), $passwordHashFromCookie)) { @@ -57,7 +57,7 @@ public function handle(Request $request, Closure $next): mixed } return tap($next($request), function () use ($request) { - if (! is_null($this->guard()->user())) { // @phpstan-ignore method.notFound + if (! is_null($this->guard()->user())) { $this->storePasswordHashInSession($request); } }); @@ -74,7 +74,7 @@ protected function storePasswordHashInSession(Request $request): void $passwordHash = $request->user()->getAuthPassword(); - $passwordHash = $this->guard()->hashPasswordForCookie($passwordHash); // @phpstan-ignore method.notFound + $passwordHash = $this->guard()->hashPasswordForCookie($passwordHash); // @phpstan-ignore method.notFound (proxied to SessionGuard via AuthManager::__call) $request->session()->put([ 'password_hash_' . $this->auth->getDefaultDriver() => $passwordHash, @@ -91,7 +91,7 @@ protected function storePasswordHashInSession(Request $request): void protected function validatePasswordHash(string $passwordHash, mixed $storedValue): bool { return is_string($storedValue) - && hash_equals($this->guard()->hashPasswordForCookie($passwordHash), $storedValue); // @phpstan-ignore method.notFound + && hash_equals($this->guard()->hashPasswordForCookie($passwordHash), $storedValue); // @phpstan-ignore method.notFound (proxied to SessionGuard via AuthManager::__call) } /** @@ -101,7 +101,7 @@ protected function validatePasswordHash(string $passwordHash, mixed $storedValue */ protected function logout(Request $request): void { - $this->guard()->logoutCurrentDevice(); // @phpstan-ignore method.notFound + $this->guard()->logoutCurrentDevice(); // @phpstan-ignore method.notFound (proxied to SessionGuard via AuthManager::__call) $request->session()->flush(); diff --git a/src/signal/src/SignalManager.php b/src/signal/src/SignalManager.php index 4be7d3656d..6f2c58836f 100644 --- a/src/signal/src/SignalManager.php +++ b/src/signal/src/SignalManager.php @@ -149,6 +149,8 @@ protected function resolveHandlers(string $process): array */ protected function getQueue(): SplPriorityQueue { + // Published configuration may register handlers in any form, which the loop validates. + /** @var array $handlers */ $handlers = $this->config->array('signal.handlers'); $queue = new SplPriorityQueue; diff --git a/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php b/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php index 6388f13d99..39310fb6c3 100644 --- a/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php +++ b/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php @@ -33,7 +33,7 @@ public function send(mixed $notifiable, Notification $notification): ?ResponseIn } return $this->http->post($url, $this->buildJsonPayload( - $notification->toSlack($notifiable) // @phpstan-ignore method.notFound + $notification->toSlack($notifiable) // @phpstan-ignore method.notFound (notifications sent through this channel define toSlack()) )); } diff --git a/src/slack-notification-channel/src/Messages/SlackAttachment.php b/src/slack-notification-channel/src/Messages/SlackAttachment.php index a6b11e334e..4c96efc10c 100644 --- a/src/slack-notification-channel/src/Messages/SlackAttachment.php +++ b/src/slack-notification-channel/src/Messages/SlackAttachment.php @@ -105,6 +105,8 @@ class SlackAttachment /** * Set the title of the attachment. + * + * @return $this */ public function title(string $title, ?string $url = null): static { @@ -116,6 +118,8 @@ public function title(string $title, ?string $url = null): static /** * Set the pretext of the attachment. + * + * @return $this */ public function pretext(string $pretext): static { @@ -126,6 +130,8 @@ public function pretext(string $pretext): static /** * Set the content (text) of the attachment. + * + * @return $this */ public function content(string $content): static { @@ -136,6 +142,8 @@ public function content(string $content): static /** * A plain-text summary of the attachment. + * + * @return $this */ public function fallback(string $fallback): static { @@ -146,6 +154,8 @@ public function fallback(string $fallback): static /** * Set the color of the attachment. + * + * @return $this */ public function color(string $color): static { @@ -156,6 +166,8 @@ public function color(string $color): static /** * Add a field to the attachment. + * + * @return $this */ public function field(Closure|string $title, string $content = ''): static { @@ -177,6 +189,8 @@ public function field(Closure|string $title, string $content = ''): static /** * Set the fields of the attachment. + * + * @return $this */ public function fields(array $fields): static { @@ -187,6 +201,8 @@ public function fields(array $fields): static /** * Set the fields containing markdown. + * + * @return $this */ public function markdown(array $fields): static { @@ -197,6 +213,8 @@ public function markdown(array $fields): static /** * Set the image URL. + * + * @return $this */ public function image(string $url): static { @@ -207,6 +225,8 @@ public function image(string $url): static /** * Set the URL to the attachment thumbnail. + * + * @return $this */ public function thumb(string $url): static { @@ -217,6 +237,8 @@ public function thumb(string $url): static /** * Add an action (button) under the attachment. + * + * @return $this */ public function action(string $title, string $url, string $style = ''): static { @@ -232,6 +254,8 @@ public function action(string $title, string $url, string $style = ''): static /** * Set the author of the attachment. + * + * @return $this */ public function author(string $name, ?string $link = null, ?string $icon = null): static { @@ -244,6 +268,8 @@ public function author(string $name, ?string $link = null, ?string $icon = null) /** * Set the footer content. + * + * @return $this */ public function footer(string $footer): static { @@ -254,6 +280,8 @@ public function footer(string $footer): static /** * Set the footer icon. + * + * @return $this */ public function footerIcon(string $icon): static { @@ -264,6 +292,8 @@ public function footerIcon(string $icon): static /** * Set the timestamp a DateTimeInterface, DateInterval, or the number of seconds that should be added to the current time. + * + * @return $this */ public function timestamp(DateInterval|DateTimeInterface|int $timestamp): static { @@ -274,6 +304,8 @@ public function timestamp(DateInterval|DateTimeInterface|int $timestamp): static /** * Set the callback ID. + * + * @return $this */ public function callbackId(string $callbackId): static { diff --git a/src/slack-notification-channel/src/Messages/SlackAttachmentField.php b/src/slack-notification-channel/src/Messages/SlackAttachmentField.php index 81b60868c7..ebc753ca77 100644 --- a/src/slack-notification-channel/src/Messages/SlackAttachmentField.php +++ b/src/slack-notification-channel/src/Messages/SlackAttachmentField.php @@ -23,6 +23,8 @@ class SlackAttachmentField /** * Set the title of the field. + * + * @return $this */ public function title(string $title): static { @@ -33,6 +35,8 @@ public function title(string $title): static /** * Set the content of the field. + * + * @return $this */ public function content(string $content): static { @@ -43,6 +47,8 @@ public function content(string $content): static /** * Indicates that the content should not be displayed side-by-side with other fields. + * + * @return $this */ public function long(): static { diff --git a/src/slack-notification-channel/src/Messages/SlackMessage.php b/src/slack-notification-channel/src/Messages/SlackMessage.php index 8c5e769621..b1a4b74715 100644 --- a/src/slack-notification-channel/src/Messages/SlackMessage.php +++ b/src/slack-notification-channel/src/Messages/SlackMessage.php @@ -72,6 +72,8 @@ class SlackMessage /** * Indicate that the notification gives information about an operation. + * + * @return $this */ public function info(): static { @@ -82,6 +84,8 @@ public function info(): static /** * Indicate that the notification gives information about a successful operation. + * + * @return $this */ public function success(): static { @@ -92,6 +96,8 @@ public function success(): static /** * Indicate that the notification gives information about a warning. + * + * @return $this */ public function warning(): static { @@ -102,6 +108,8 @@ public function warning(): static /** * Indicate that the notification gives information about an error. + * + * @return $this */ public function error(): static { @@ -112,6 +120,8 @@ public function error(): static /** * Set a custom username and optional emoji icon for the Slack message. + * + * @return $this */ public function from(string $username, ?string $icon = null): static { @@ -126,6 +136,8 @@ public function from(string $username, ?string $icon = null): static /** * Set a custom image icon the message should use. + * + * @return $this */ public function image(string $image): static { @@ -136,6 +148,8 @@ public function image(string $image): static /** * Set the Slack channel the message should be sent to. + * + * @return $this */ public function to(?string $channel): static { @@ -146,6 +160,8 @@ public function to(?string $channel): static /** * Set the content of the Slack message. + * + * @return $this */ public function content(string $content): static { @@ -156,6 +172,8 @@ public function content(string $content): static /** * Define an attachment for the message. + * + * @return $this */ public function attachment(Closure $callback): static { @@ -185,6 +203,8 @@ public function color(): ?string /** * Find and link channel names and usernames. + * + * @return $this */ public function linkNames(): static { @@ -195,6 +215,8 @@ public function linkNames(): static /** * Unfurl links to rich display. + * + * @return $this */ public function unfurlLinks(bool $unfurlLinks): static { @@ -205,6 +227,8 @@ public function unfurlLinks(bool $unfurlLinks): static /** * Unfurl media to rich display. + * + * @return $this */ public function unfurlMedia(bool $unfurlMedia): static { @@ -215,6 +239,8 @@ public function unfurlMedia(bool $unfurlMedia): static /** * Set additional request options for the Guzzle HTTP client. + * + * @return $this */ public function http(array $options): static { diff --git a/src/slack-notification-channel/src/Slack/SlackChannel.php b/src/slack-notification-channel/src/Slack/SlackChannel.php index 960abd0988..8c54020928 100644 --- a/src/slack-notification-channel/src/Slack/SlackChannel.php +++ b/src/slack-notification-channel/src/Slack/SlackChannel.php @@ -33,7 +33,7 @@ public function send(mixed $notifiable, Notification $notification): ?Response { $route = $this->determineRoute($notifiable, $notification); - $message = $notification->toSlack($notifiable); // @phpstan-ignore method.notFound + $message = $notification->toSlack($notifiable); // @phpstan-ignore method.notFound (notifications sent through this channel define toSlack()) $payload = $this->buildJsonPayload($message, $route); diff --git a/src/socialite/src/AbstractProvider.php b/src/socialite/src/AbstractProvider.php index 6a17f0d6ff..e7a219445b 100644 --- a/src/socialite/src/AbstractProvider.php +++ b/src/socialite/src/AbstractProvider.php @@ -50,6 +50,8 @@ public function __construct(Request $request, protected array $guzzle = []) * * Boot-only. The configuration persists for the worker lifetime and affects * every subsequent request. Use setConfig() for per-request overrides. + * + * @return $this */ public function withConfig(#[SensitiveParameter] array $config): static { @@ -63,6 +65,8 @@ public function withConfig(#[SensitiveParameter] array $config): static * * The override is isolated to the current coroutine and must be applied * independently on the redirect and callback requests. + * + * @return $this */ public function setConfig(#[SensitiveParameter] array $config): static { @@ -104,6 +108,8 @@ protected function getHttpClient(): Client /** * Set the Guzzle HTTP client instance. + * + * @return $this */ public function setHttpClient(Client $client): static { @@ -117,6 +123,8 @@ public function setHttpClient(Client $client): static * * Stores the request in coroutine context so cached providers read the * current request without leaking it to concurrent coroutines. + * + * @return $this */ public function setRequest(Request $request): static { @@ -159,6 +167,8 @@ protected function isStateless(): bool /** * Indicate that the provider should operate as stateless. + * + * @return $this */ public function stateless(): static { @@ -177,6 +187,8 @@ protected function getState(): string /** * Set the custom parameters of the request. + * + * @return $this */ public function with(array $parameters): static { diff --git a/src/socialite/src/AbstractUser.php b/src/socialite/src/AbstractUser.php index 78f42f2c0f..eedf06cafc 100644 --- a/src/socialite/src/AbstractUser.php +++ b/src/socialite/src/AbstractUser.php @@ -94,6 +94,8 @@ public function getRaw(): array /** * Set the raw user array from the provider. + * + * @return $this */ public function setRaw(array $user): static { @@ -104,6 +106,8 @@ public function setRaw(array $user): static /** * Map the given array onto the user's properties. + * + * @return $this */ public function map(array $attributes): static { diff --git a/src/socialite/src/Exceptions/DriverMissingConfigurationException.php b/src/socialite/src/Exceptions/DriverMissingConfigurationException.php index 945ed768cc..68c40ee8bb 100644 --- a/src/socialite/src/Exceptions/DriverMissingConfigurationException.php +++ b/src/socialite/src/Exceptions/DriverMissingConfigurationException.php @@ -15,7 +15,6 @@ class DriverMissingConfigurationException extends InvalidArgumentException */ public static function make(string $provider, array $keys): static { - /* @phpstan-ignore new.static */ return new static('Missing required configuration keys [' . implode(', ', $keys) . "] for [{$provider}] OAuth provider."); } } diff --git a/src/socialite/src/Testing/SocialiteFake.php b/src/socialite/src/Testing/SocialiteFake.php index faa69cc429..4022bc932a 100644 --- a/src/socialite/src/Testing/SocialiteFake.php +++ b/src/socialite/src/Testing/SocialiteFake.php @@ -43,6 +43,8 @@ public function driver(UnitEnum|string|null $driver = null): Provider /** * Register a fake user for the given driver. + * + * @return $this */ public function fake(string $driver, UserContract|Closure|null $user = null): static { diff --git a/src/socialite/src/Two/AbstractProvider.php b/src/socialite/src/Two/AbstractProvider.php index d0d33e22b3..acd6d8e791 100644 --- a/src/socialite/src/Two/AbstractProvider.php +++ b/src/socialite/src/Two/AbstractProvider.php @@ -176,6 +176,8 @@ protected function getUser(): ?User /** * Set the user instance in the context. + * + * @return $this */ protected function setUser(User $user): static { @@ -379,6 +381,8 @@ protected function getCode(): string /** * Merge the scopes of the requested access. + * + * @return $this */ public function scopes(array|string $scopes): static { @@ -391,6 +395,8 @@ public function scopes(array|string $scopes): static /** * Set the scopes of the requested access. + * + * @return $this */ public function setScopes(array|string $scopes): static { @@ -419,6 +425,7 @@ public function getScopes(): array * every subsequent request. Use setConfig() for per-request redirect values. * * @param Closure(array): string $formatter + * @return $this */ public function withRedirectFormatter(Closure $formatter): static { @@ -429,6 +436,8 @@ public function withRedirectFormatter(Closure $formatter): static /** * Set the redirect URL. + * + * @return $this */ public function redirectUrl(string $url): static { @@ -513,6 +522,8 @@ protected function usesPKCE(): bool /** * Enable PKCE for the provider. + * + * @return $this */ public function enablePKCE(): static { @@ -552,6 +563,8 @@ protected function getCodeChallengeMethod(): string * * Extends the base setConfig to also handle OAuth2-specific credential * keys (client_id, client_secret, redirect) in coroutine context. + * + * @return $this */ public function setConfig(#[SensitiveParameter] array $config): static { diff --git a/src/socialite/src/Two/FacebookProvider.php b/src/socialite/src/Two/FacebookProvider.php index ac80d03802..d2a468a32d 100644 --- a/src/socialite/src/Two/FacebookProvider.php +++ b/src/socialite/src/Two/FacebookProvider.php @@ -195,6 +195,8 @@ protected function getCodeFields(?string $state = null): array /** * Set the user fields to request from Facebook. + * + * @return $this */ public function fields(array $fields): static { @@ -213,6 +215,8 @@ protected function getFields(): array /** * Set the dialog to be displayed as a popup. + * + * @return $this */ public function asPopup(): static { @@ -231,6 +235,8 @@ protected function getPopup(): bool /** * Re-request permissions which were previously declined. + * + * @return $this */ public function reRequest(): static { @@ -257,6 +263,8 @@ public function lastToken(): ?string /** * Set the last access token used. + * + * @return $this */ protected function setLastToken(#[SensitiveParameter] string $token): static { @@ -267,6 +275,8 @@ protected function setLastToken(#[SensitiveParameter] string $token): static /** * Specify the nonce expected when using Facebook Limited Login OIDC tokens. + * + * @return $this */ public function withNonce(string $nonce): static { @@ -285,6 +295,8 @@ protected function getExpectedNonce(): ?string /** * Specify which graph version should be used. + * + * @return $this */ public function usingGraphVersion(string $version): static { diff --git a/src/socialite/src/Two/GitlabProvider.php b/src/socialite/src/Two/GitlabProvider.php index 288d9507e6..a06024ed4b 100644 --- a/src/socialite/src/Two/GitlabProvider.php +++ b/src/socialite/src/Two/GitlabProvider.php @@ -26,6 +26,8 @@ class GitlabProvider extends AbstractProvider implements ProviderInterface /** * Set the Gitlab instance host. + * + * @return $this */ public function setHost(?string $host): static { diff --git a/src/socialite/src/Two/SlackProvider.php b/src/socialite/src/Two/SlackProvider.php index ffd75705d6..1612a89811 100644 --- a/src/socialite/src/Two/SlackProvider.php +++ b/src/socialite/src/Two/SlackProvider.php @@ -22,6 +22,8 @@ class SlackProvider extends AbstractProvider implements ProviderInterface /** * Indicate that the requested token should be for a bot user. + * + * @return $this */ public function asBotUser(): static { @@ -40,6 +42,8 @@ protected function getScopeKey(): string /** * Indicate that the requested token should be for a user. + * + * @return $this */ protected function setScopeKey(string $scopeKey): static { diff --git a/src/socialite/src/Two/User.php b/src/socialite/src/Two/User.php index 9df9190cc2..900acc575e 100644 --- a/src/socialite/src/Two/User.php +++ b/src/socialite/src/Two/User.php @@ -62,6 +62,8 @@ public static function fake(#[SensitiveParameter] array $attributes = []): stati /** * Set the token on the user. + * + * @return $this */ public function setToken(#[SensitiveParameter] ?string $token): static { @@ -72,6 +74,8 @@ public function setToken(#[SensitiveParameter] ?string $token): static /** * Set the refresh token required to obtain a new access token. + * + * @return $this */ public function setRefreshToken(#[SensitiveParameter] ?string $refreshToken): static { @@ -82,6 +86,8 @@ public function setRefreshToken(#[SensitiveParameter] ?string $refreshToken): st /** * Set the number of seconds the access token is valid for. + * + * @return $this */ public function setExpiresIn(?int $expiresIn): static { @@ -92,6 +98,8 @@ public function setExpiresIn(?int $expiresIn): static /** * Set the scopes that were approved by the user during authentication. + * + * @return $this */ public function setApprovedScopes(array $approvedScopes): static { @@ -102,6 +110,8 @@ public function setApprovedScopes(array $approvedScopes): static /** * Set the complete access token response on the user. + * + * @return $this */ public function setAccessTokenResponseBody(#[SensitiveParameter] array $accessTokenResponseBody): static { diff --git a/src/support/composer.json b/src/support/composer.json index 3bdcaccf02..c541a18035 100644 --- a/src/support/composer.json +++ b/src/support/composer.json @@ -55,7 +55,7 @@ "nesbot/carbon": "^3.13.1", "phpoption/phpoption": "^1.9", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/process": "^8.1", "symfony/uid": "^8.1", "vlucas/phpdotenv": "^5.6.1", diff --git a/src/support/src/Aws/SerializedCredentialProvider.php b/src/support/src/Aws/SerializedCredentialProvider.php index c77caf0146..9c76782005 100644 --- a/src/support/src/Aws/SerializedCredentialProvider.php +++ b/src/support/src/Aws/SerializedCredentialProvider.php @@ -7,8 +7,9 @@ use Closure; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; +use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\Locker; -use Hypervel\Engine\Coroutine; +use Hypervel\Engine\Coroutine as EngineCoroutine; use Swoole\Coroutine\CanceledException; use Throwable; @@ -56,7 +57,7 @@ protected function identity(callable $provider): string public function __invoke(mixed ...$arguments): PromiseInterface { // Waiting drains Guzzle callbacks, which may request credentials again. - $coroutine = Coroutine::id(); + $coroutine = EngineCoroutine::id(); $acquiresLock = (static::$owners[$this->lockKey] ?? null) !== $coroutine; if ($acquiresLock) { @@ -69,11 +70,13 @@ public function __invoke(mixed ...$arguments): PromiseInterface try { return Create::promiseFor(($this->provider)(...$arguments)->wait()); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + // SDK providers can wrap an interrupted fetch in CredentialsException. - if (Coroutine::isCanceled()) { + if (EngineCoroutine::isCanceled()) { throw new CanceledException('Resolving AWS credentials was canceled.', previous: $exception); } diff --git a/src/support/src/Benchmark.php b/src/support/src/Benchmark.php index 65f1741451..2ce5c11f21 100644 --- a/src/support/src/Benchmark.php +++ b/src/support/src/Benchmark.php @@ -14,8 +14,10 @@ class Benchmark /** * Measure a callable or array of callables over the given number of iterations. * - * @param array|Closure $benchmarkables - * @return array|float + * @template TKey of array-key + * + * @param array|Closure $benchmarkables + * @return ($benchmarkables is Closure ? float : array) */ public static function measure(Closure|array $benchmarkables, int $iterations = 1): array|float { diff --git a/src/support/src/Composer.php b/src/support/src/Composer.php index 273030ea2e..f57fdd8d53 100644 --- a/src/support/src/Composer.php +++ b/src/support/src/Composer.php @@ -6,6 +6,7 @@ use Closure; use Composer\Autoload\ClassLoader; +use Composer\InstalledVersions; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Traits\GuardsProcessOutput; use JsonException; @@ -233,6 +234,8 @@ protected function getProcess(array $command, array $env = []): Process /** * Set the working path used by the class. + * + * @return $this */ public function setWorkingPath(string $path): static { @@ -285,11 +288,20 @@ public static function setLoader(ClassLoader $classLoader): ClassLoader /** * Find the application Composer autoloader registered with a vendor directory. + * + * A tool shipped as a PHAR, such as PHPStan, registers its own autoloader first, so a + * loader outside a PHAR is preferred. */ protected static function findLoader(): ClassLoader { $loaders = ClassLoader::getRegisteredLoaders(); + foreach ($loaders as $vendorDirectory => $loader) { + if (! str_starts_with($vendorDirectory, 'phar://')) { + return $loader; + } + } + if ($loaders !== []) { return reset($loaders); } @@ -297,6 +309,74 @@ protected static function findLoader(): ClassLoader throw new RuntimeException('Composer loader not found.'); } + /** + * Get the root package of the application's Composer installation. + * + * A tool shipped as a PHAR, such as PHPStan, registers its own installation first, so + * InstalledVersions::getRootPackage() returns the tool's. The first installation outside + * a PHAR is preferred. + * + * @return array{name: string, pretty_version: string, version: string, reference: null|string, type: string, install_path: string, aliases: string[], dev: bool} + */ + public static function rootPackage(): array + { + foreach (InstalledVersions::getAllRawData() as $installed) { + if (! str_starts_with($installed['root']['install_path'], 'phar://')) { + return $installed['root']; + } + } + + return InstalledVersions::getRootPackage(); + } + + /** + * Get the vendor directory of the Composer install rooted at the given path. + * + * The loaded install records its own root, so a vendor directory chosen through + * project or global configuration, or while installing, needs no Composer + * configuration parsing. A relative COMPOSER_VENDOR_DIR is relative to the root. + */ + public static function vendorPath(string $rootPath): string + { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Composer sets this in the process environment) + $vendorPath = Env::get('COMPOSER_VENDOR_DIR'); + + if (is_string($vendorPath) && $vendorPath !== '') { + return str_starts_with($vendorPath, '/') ? $vendorPath : $rootPath . '/' . $vendorPath; + } + + $root = realpath($rootPath); + + if ($root !== false) { + $loaders = ClassLoader::getRegisteredLoaders(); + + // The default layout needs no installed.php read, which is large and runs + // whenever an application builds its package manifest. + $defaultVendorDirectory = realpath($rootPath . '/vendor'); + + if ($defaultVendorDirectory !== false && isset($loaders[$defaultVendorDirectory])) { + return $rootPath . '/vendor'; + } + + foreach (array_keys($loaders) as $vendorDirectory) { + $installedPath = $vendorDirectory . '/composer/installed.php'; + + if (! is_file($installedPath)) { + continue; + } + + /** @var array{root: array{install_path: string}} $installed */ + $installed = require $installedPath; + + if (realpath($installed['root']['install_path']) === $root) { + return $vendorDirectory; + } + } + } + + return $rootPath . '/vendor'; + } + /** * Flush all static state. */ diff --git a/src/support/src/DefaultProviders.php b/src/support/src/DefaultProviders.php index 4fc4dab41a..97581f0cbc 100644 --- a/src/support/src/DefaultProviders.php +++ b/src/support/src/DefaultProviders.php @@ -94,7 +94,7 @@ public function replace(array $replacements): static $current = is_int($key) ? $current->replace([$key => $to]) : $current; } - return new static($current->values()->toArray()); + return new static($current->values()->all()); } /** @@ -107,7 +107,7 @@ public function except(array $providers): static return new static((new Collection($this->providers)) ->diff($providers) ->values() - ->toArray()); + ->all()); } /** diff --git a/src/support/src/Defer/DeferredCallback.php b/src/support/src/Defer/DeferredCallback.php index 202839972f..ae4efd6c19 100644 --- a/src/support/src/Defer/DeferredCallback.php +++ b/src/support/src/Defer/DeferredCallback.php @@ -28,6 +28,8 @@ public function __construct( /** * Specify the callback name. + * + * @return $this */ public function name(string $name): static { @@ -38,6 +40,8 @@ public function name(string $name): static /** * Indicate that the callback should run on unsuccessful requests and jobs. + * + * @return $this */ public function always(bool $always = true): static { diff --git a/src/support/src/Defer/DeferredCallbackCollection.php b/src/support/src/Defer/DeferredCallbackCollection.php index 6975daa50d..e4808d7a9c 100644 --- a/src/support/src/Defer/DeferredCallbackCollection.php +++ b/src/support/src/Defer/DeferredCallbackCollection.php @@ -89,6 +89,8 @@ public function forget(string $name): void /** * Remove any duplicate callbacks. + * + * @return $this */ protected function forgetDuplicates(): static { diff --git a/src/support/src/Env.php b/src/support/src/Env.php index caa00063dd..a64d9c9b2f 100644 --- a/src/support/src/Env.php +++ b/src/support/src/Env.php @@ -188,6 +188,11 @@ protected static function buildAdapters(): array /** * Get the value of an environment variable. + * + * @template TDefault + * + * @param (Closure(): TDefault)|TDefault $default + * @return null|bool|string|TDefault */ public static function get(string $key, mixed $default = null): mixed { @@ -204,6 +209,7 @@ public static function get(string $key, mixed $default = null): mixed */ public static function getArray(string $key, array $default = []): array { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (getArray() is an Env reader built on get()) $value = static::get($key); if (! filled($value)) { diff --git a/src/support/src/Facades/Concurrency.php b/src/support/src/Facades/Concurrency.php index 6a8ef7db2e..665c4fd027 100644 --- a/src/support/src/Facades/Concurrency.php +++ b/src/support/src/Facades/Concurrency.php @@ -10,17 +10,17 @@ * @method static \Hypervel\Concurrency\CoroutineDriver createCoroutineDriver() * @method static \Hypervel\Concurrency\ProcessDriver createProcessDriver() * @method static \Hypervel\Concurrency\SyncDriver createSyncDriver() - * @method static mixed driver(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Contracts\Concurrency\Driver driver(\UnitEnum|string|null $name = null) * @method static \Hypervel\Concurrency\ConcurrencyManager extend(string $name, \Closure $callback) * @method static \Hypervel\Concurrency\ConcurrencyManager forgetInstance(array|string|null $name = null) * @method static string getDefaultInstance() * @method static array getInstanceConfig(string $name) * @method static mixed instance(string|null $name = null) * @method static void purge(string|null $name = null) + * @method static array run(\Closure|array $tasks, \Carbon\CarbonInterval|int|null $timeout = null) * @method static \Hypervel\Concurrency\ConcurrencyManager setApplication(\Hypervel\Contracts\Foundation\Application $app) * @method static void setDefaultInstance(string $name) * @method static \Hypervel\Support\Defer\DeferredCallback defer(\Closure|array $tasks) - * @method static array run(\Closure|array $tasks, \Carbon\CarbonInterval|int|null $timeout = null) * * @see \Hypervel\Concurrency\ConcurrencyManager */ diff --git a/src/support/src/Facades/Event.php b/src/support/src/Facades/Event.php index f0031f2e13..1fb3d39a74 100644 --- a/src/support/src/Facades/Event.php +++ b/src/support/src/Facades/Event.php @@ -10,7 +10,7 @@ /** * @method static \Closure createClassListener(array|string $listener, bool $wildcard = false) * @method static mixed defer(callable $callback, null|string[] $events = null) - * @method static mixed dispatch(object|string $event, mixed $payload = [], bool $halt = false) + * @method static ($halt is true ? mixed : null|array) dispatch(object|string $event, mixed $payload = [], bool $halt = false) * @method static void flush(string $event) * @method static void flushMacros() * @method static void flushState() diff --git a/src/support/src/Facades/MaintenanceMode.php b/src/support/src/Facades/MaintenanceMode.php index b488c18ec8..cc70b1c4eb 100644 --- a/src/support/src/Facades/MaintenanceMode.php +++ b/src/support/src/Facades/MaintenanceMode.php @@ -14,6 +14,10 @@ * @method static string getDefaultDriver() * @method static array getDrivers() * @method static \Hypervel\Foundation\MaintenanceModeManager setContainer(\Hypervel\Contracts\Container\Container $container) + * @method static void activate(array $payload) + * @method static bool active() + * @method static array data() + * @method static void deactivate() * * @see \Hypervel\Foundation\MaintenanceModeManager */ diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php index 103ec1f78e..c8c678e987 100644 --- a/src/support/src/Facades/Request.php +++ b/src/support/src/Facades/Request.php @@ -12,14 +12,14 @@ * @method static bool acceptsMarkdown() * @method static bool ajax() * @method static array all(mixed $keys = null) - * @method static array allFiles() + * @method static array|\Hypervel\Http\UploadedFile> allFiles() * @method static bool anyFilled(array|string $keys) * @method static array array(array|string|null $key = null) * @method static string|null bearerToken() * @method static ($strict is true ? bool|null : bool) boolean(string|null $key = null, \Closure|bool|null $default = false, bool $strict = false) * @method static int|float clamp(string $key, int|float $min, int|float $max, int|float $default = 0) * @method static \Hypervel\Support\Collection collect(array|string|null $key = null) - * @method static array|string|null cookie(string|null $key = null, array|string|null $default = null) + * @method static ($key is null ? array : mixed) cookie(string|null $key = null, mixed $default = null) * @method static \Hypervel\Http\Request create(string $uri, string $method = 'GET', array $parameters = [], array $cookies = [], array $files = [], array $server = [], string|resource|null $content = null) * @method static \Hypervel\Http\Request createFrom(\Hypervel\Http\Request $from, \Hypervel\Http\Request|null $to = null) * @method static \Hypervel\Http\Request createFromBase(\Symfony\Component\HttpFoundation\Request $request) @@ -30,12 +30,12 @@ * @method static \Hypervel\Http\Request dump(mixed $keys = []) * @method static \Hypervel\Http\Request duplicate(array|null $query = null, array|null $request = null, array|null $attributes = null, array|null $cookies = null, array|null $files = null, array|null $server = null) * @method static void enableHttpMethodParameterOverride() - * @method static \BackedEnum|null enum(string $key, string $enumClass, \BackedEnum|null $default = null) + * @method static \BackedEnum|null enum(string $key, string $enumClass, \Closure|\BackedEnum|null $default = null) * @method static \BackedEnum[] enums(string $key, string $enumClass) * @method static array except(mixed $keys) * @method static bool exists(array|string $key) * @method static bool expectsJson() - * @method static ($key is null ? array : null|\Hypervel\Http\UploadedFile|\Hypervel\Http\UploadedFile[]) file(string|null $key = null, mixed $default = null) + * @method static ($key is null ? array|\Hypervel\Http\UploadedFile> : mixed) file(string|null $key = null, mixed $default = null) * @method static bool filled(array|string $key) * @method static array filterPrecognitiveRules(array $rules) * @method static string fingerprint() @@ -110,7 +110,7 @@ * @method static bool hasValidRelativeSignatureWhileIgnoring(mixed $ignoreQuery = []) * @method static bool hasValidSignature(bool $absolute = true) * @method static bool hasValidSignatureWhileIgnoring(mixed $ignoreQuery = [], mixed $absolute = true) - * @method static array|string|null header(string|null $key = null, array|string|null $default = null) + * @method static ($key is null ? array> : null|string) header(string|null $key = null, array|string|null $default = null) * @method static string host() * @method static string httpHost() * @method static \Hypervel\Image\Image|null image(string $key) @@ -150,20 +150,20 @@ * @method static void overrideGlobals() * @method static string path() * @method static bool pjax() - * @method static array|string|null post(string|null $key = null, array|string|null $default = null) + * @method static ($key is null ? array : mixed) post(string|null $key = null, mixed $default = null) * @method static string|null prefers(array|string $contentTypes) * @method static bool preferSafeContent() * @method static bool prefetch() - * @method static array|string|null query(string|null $key = null, array|string|null $default = null) + * @method static ($key is null ? array : mixed) query(string|null $key = null, mixed $default = null) * @method static \Hypervel\Http\Request replace(array $input) * @method static string root() - * @method static ($param is null ? null|\Hypervel\Routing\Route : null|object|string) route(string|null $param = null, mixed $default = null) + * @method static ($param is null ? null|\Hypervel\Routing\Route : mixed) route(string|null $param = null, mixed $default = null) * @method static bool routeIs(mixed ...$patterns) * @method static string schemeAndHttpHost() * @method static bool secure() * @method static string|null segment(int $index, string|null $default = null) * @method static array segments() - * @method static array|string|int|float|null server(string|null $key = null, array|string|int|float|null $default = null) + * @method static ($key is null ? array : mixed) server(string|null $key = null, mixed $default = null) * @method static \Hypervel\Contracts\Session\Session session() * @method static void setAllowedHttpMethodOverride(string[]|null $methods) * @method static void setDefaultLocale(string $locale) diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php index 35aea74253..d0a84111f2 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -14,13 +14,13 @@ /** * @method static \Hypervel\Filesystem\FilesystemManager addPoolableDriver(string $driver) * @method static \Hypervel\Contracts\Filesystem\Filesystem build(array|string $config, string|null $name = null) - * @method static \Hypervel\Contracts\Filesystem\Filesystem createFtpDriver(array $config) - * @method static \Hypervel\Contracts\Filesystem\Cloud createGcsDriver(array $config) - * @method static \Hypervel\Contracts\Filesystem\Filesystem createLocalDriver(array $config, string $name = 'local') - * @method static \Hypervel\Contracts\Filesystem\Filesystem createReadThroughDriver(array $config, string $name = 'read-through') - * @method static \Hypervel\Contracts\Filesystem\Cloud createS3Driver(array $config) + * @method static \Hypervel\Filesystem\FilesystemAdapter createFtpDriver(array $config) + * @method static \Hypervel\Filesystem\GoogleCloudStorageAdapter createGcsDriver(array $config) + * @method static \Hypervel\Filesystem\LocalFilesystemAdapter createLocalDriver(array $config, string $name = 'local') + * @method static \Hypervel\Filesystem\ReadThroughFilesystem createReadThroughDriver(array $config, string $name = 'read-through') + * @method static \Hypervel\Filesystem\AwsS3V3Adapter createS3Driver(array $config) * @method static \Hypervel\Contracts\Filesystem\Filesystem createScopedDriver(array $config, string|null $name = null) - * @method static \Hypervel\Contracts\Filesystem\Filesystem createSftpDriver(array $config) + * @method static \Hypervel\Filesystem\FilesystemAdapter createSftpDriver(array $config) * @method static \Hypervel\Contracts\Filesystem\Filesystem disk(UnitEnum|string|null $name = null) * @method static \Hypervel\Contracts\Filesystem\Filesystem drive(UnitEnum|string|null $name = null) * @method static \Hypervel\Filesystem\FilesystemManager extend(string $driver, \Closure $callback, bool $poolable = false) diff --git a/src/support/src/FailureReporter.php b/src/support/src/FailureReporter.php new file mode 100644 index 0000000000..6333acb41d --- /dev/null +++ b/src/support/src/FailureReporter.php @@ -0,0 +1,68 @@ +has(ExceptionHandler::class)) { + $container->make(ExceptionHandler::class)->report($exception); + + return; + } + } catch (CanceledException) { + return; + } catch (Throwable) { + } + + try { + error_log((string) $exception); + } catch (Throwable) { + } + } + + /** + * Select the kept failure like Coroutine::preferCancellation() and report the one it displaces. + */ + public static function preferCancellation(?Throwable $kept, Throwable $next): Throwable + { + $selected = Coroutine::preferCancellation($kept, $next); + $displaced = $selected === $next ? $kept : $next; + + if ($displaced !== null && $displaced !== $selected) { + self::report($displaced); + } + + return $selected; + } +} diff --git a/src/support/src/Fluent.php b/src/support/src/Fluent.php index 22ee3ce701..8564f24db7 100644 --- a/src/support/src/Fluent.php +++ b/src/support/src/Fluent.php @@ -73,6 +73,8 @@ public function get(string|int|null $key = null, mixed $default = null): mixed /** * Set an attribute on the fluent instance using "dot" notation. + * + * @return $this */ public function set(string|int $key, mixed $value): static { @@ -85,6 +87,7 @@ public function set(string|int $key, mixed $value): static * Fill the fluent instance with an array of attributes. * * @param array|object $attributes + * @return $this */ public function fill(array|object $attributes): static { diff --git a/src/support/src/Lottery.php b/src/support/src/Lottery.php index 58a0a1dcbe..a653d59ed4 100644 --- a/src/support/src/Lottery.php +++ b/src/support/src/Lottery.php @@ -71,6 +71,8 @@ public static function odds(int|float $chances, ?int $outOf = null): static /** * Set the winner callback. + * + * @return $this */ public function winner(callable $callback): static { @@ -81,6 +83,8 @@ public function winner(callable $callback): static /** * Set the loser callback. + * + * @return $this */ public function loser(callable $callback): static { diff --git a/src/support/src/Manager.php b/src/support/src/Manager.php index db3a5cc777..3d76fc4655 100644 --- a/src/support/src/Manager.php +++ b/src/support/src/Manager.php @@ -95,15 +95,26 @@ protected function createDriver(string $driver): mixed return $this->callCustomCreator($driver); } - $method = 'create' . Str::studly($driver) . 'Driver'; + $method = $this->getCreatorMethod($driver); - if (method_exists($this, $method)) { + if ($method !== null) { return $this->{$method}(); } throw new InvalidArgumentException("Driver [{$driver}] not supported."); } + /** + * Get the name of the manager's creator method for the given driver. + */ + protected function getCreatorMethod(string $driver): ?string + { + $method = 'create' . Str::studly($driver) . 'Driver'; + + // Hypervel: a name that studly-cases to nothing, such as '' or '-', would resolve to createDriver() itself. + return $method !== 'createDriver' && method_exists($this, $method) ? $method : null; + } + /** * Call a custom driver creator. */ diff --git a/src/support/src/Optional.php b/src/support/src/Optional.php index f09f30afd2..9cfda8cc64 100644 --- a/src/support/src/Optional.php +++ b/src/support/src/Optional.php @@ -7,6 +7,9 @@ use ArrayAccess; use Hypervel\Support\Traits\Macroable; +/** + * @template TValue + */ class Optional implements ArrayAccess { use Macroable { @@ -20,6 +23,8 @@ class Optional implements ArrayAccess /** * Create a new optional instance. + * + * @param TValue $value */ public function __construct(mixed $value) { diff --git a/src/support/src/Sleep.php b/src/support/src/Sleep.php index 37804a0c30..e7ee5e64ab 100644 --- a/src/support/src/Sleep.php +++ b/src/support/src/Sleep.php @@ -110,6 +110,8 @@ public static function sleep(float|int $duration): static /** * Sleep for the given duration. Replaces any previously defined duration. + * + * @return $this */ protected function duration(DateInterval|float|int $duration): static { @@ -133,6 +135,8 @@ protected function duration(DateInterval|float|int $duration): static /** * Sleep for the given number of minutes. + * + * @return $this */ public function minutes(): static { @@ -144,6 +148,8 @@ public function minutes(): static /** * Sleep for one minute. + * + * @return $this */ public function minute(): static { @@ -152,6 +158,8 @@ public function minute(): static /** * Sleep for the given number of seconds. + * + * @return $this */ public function seconds(): static { @@ -162,6 +170,8 @@ public function seconds(): static /** * Sleep for one second. + * + * @return $this */ public function second(): static { @@ -170,6 +180,8 @@ public function second(): static /** * Sleep for the given number of milliseconds. + * + * @return $this */ public function milliseconds(): static { @@ -180,6 +192,8 @@ public function milliseconds(): static /** * Sleep for one millisecond. + * + * @return $this */ public function millisecond(): static { @@ -188,6 +202,8 @@ public function millisecond(): static /** * Sleep for the given number of microseconds. + * + * @return $this */ public function microseconds(): static { @@ -198,6 +214,8 @@ public function microseconds(): static /** * Sleep for one microsecond. + * + * @return $this */ public function microsecond(): static { @@ -206,6 +224,8 @@ public function microsecond(): static /** * Add additional time to sleep for. + * + * @return $this */ public function and(float|int $duration): static { @@ -216,6 +236,8 @@ public function and(float|int $duration): static /** * Sleep while a given callback returns "true". + * + * @return $this */ public function while(Closure $callback): static { @@ -291,10 +313,12 @@ protected function goodnight(): void while ($while()) { if ($seconds > 0) { + // @phpstan-ignore hypervel.rawSleep (the Sleep implementation itself) sleep($seconds); } if ($microseconds > 0) { + // @phpstan-ignore hypervel.rawSleep (the Sleep implementation itself) usleep($microseconds); } } @@ -426,6 +450,8 @@ public static function assertInsomniac(): void /** * Indicate that the instance should not sleep. + * + * @return $this */ protected function shouldNotSleep(): static { @@ -436,6 +462,8 @@ protected function shouldNotSleep(): static /** * Only sleep when the given condition is true. + * + * @return $this */ public function when(bool|Closure $condition): static { @@ -446,6 +474,8 @@ public function when(bool|Closure $condition): static /** * Don't sleep when the given condition is true. + * + * @return $this */ public function unless(bool|Closure $condition): static { diff --git a/src/support/src/Str.php b/src/support/src/Str.php index ee83f46c6b..ce246cde24 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1114,10 +1114,12 @@ private static function toStringOr(mixed $value, string $fallback): string /** * Replace the given value in the given string. * + * @template TKey of array-key + * * @param iterable|string $search * @param iterable|string $replace - * @param iterable|string $subject - * @return ($subject is string ? string : string[]) + * @param iterable|string $subject + * @return ($subject is string ? string : array) */ public static function replace(string|iterable $search, string|iterable $replace, string|iterable $subject, bool $caseSensitive = true): string|array { @@ -1141,10 +1143,12 @@ public static function replace(string|iterable $search, string|iterable $replace /** * Replace the given value in the given string regardless of case. * + * @template TKey of array-key + * * @param string|string[] $search * @param string|string[] $replace - * @param string|string[] $subject - * @return ($subject is string ? string : string[]) + * @param array|string $subject + * @return ($subject is string ? string : array) */ protected static function replaceWhileIgnoringCase(string|array $search, string|array $replace, string|array $subject): string|array { diff --git a/src/support/src/Stringable.php b/src/support/src/Stringable.php index abbb8876b4..531e6b3fdc 100644 --- a/src/support/src/Stringable.php +++ b/src/support/src/Stringable.php @@ -279,12 +279,14 @@ public function explode(string $delimiter, int $limit = PHP_INT_MAX): Collection /** * Split a string using a regular expression or by length. * - * @return Collection + * PREG_SPLIT_OFFSET_CAPTURE makes a pattern split return [segment, offset] pairs; a length split ignores flags. + * + * @return ($flags is int-mask ? Collection : Collection}|string>) */ public function split(string|int $pattern, int $limit = -1, int $flags = 0): Collection { - if (filter_var($pattern, FILTER_VALIDATE_INT) !== false) { - return new Collection(mb_str_split($this->value, $pattern)); + if (($length = filter_var($pattern, FILTER_VALIDATE_INT)) !== false) { + return new Collection(mb_str_split($this->value, $length)); } $segments = preg_split($pattern, $this->value, $limit, $flags); @@ -1115,6 +1117,8 @@ public function decrypt(bool $serialize = false): static /** * Dump the string. + * + * @return $this */ public function dump(mixed ...$args): static { diff --git a/src/support/src/Testing/Fakes/BusFake.php b/src/support/src/Testing/Fakes/BusFake.php index b771f001f9..4b0af6a83f 100644 --- a/src/support/src/Testing/Fakes/BusFake.php +++ b/src/support/src/Testing/Fakes/BusFake.php @@ -80,6 +80,8 @@ public function __construct( /** * Specify the jobs that should be dispatched instead of faked. + * + * @return $this */ public function except(array|string $jobsToDispatch): static { @@ -749,6 +751,8 @@ protected function shouldDispatchCommand(mixed $command): bool /** * Specify if commands should be serialized and restored when dispatched or batched. + * + * @return $this */ public function serializeAndRestore(bool $serializeAndRestore = true): static { @@ -775,6 +779,8 @@ protected function getCommandRepresentation(mixed $command): mixed /** * Set the pipes commands should be piped through before dispatching. + * + * @return $this */ public function pipeThrough(array $pipes): static { @@ -801,6 +807,8 @@ public function getCommandHandler(mixed $command): mixed /** * Map a command to a handler. + * + * @return $this */ public function map(array $map): static { @@ -813,6 +821,8 @@ public function map(array $map): static * Allow dispatching after responses. * * Tests only. Changes the underlying dispatcher for all coroutines until the setting is restored. + * + * @return $this */ public function withDispatchingAfterResponses(): static { @@ -825,6 +835,8 @@ public function withDispatchingAfterResponses(): static * Disable dispatching after responses. * * Tests only. Changes the underlying dispatcher for all coroutines until the setting is restored. + * + * @return $this */ public function withoutDispatchingAfterResponses(): static { diff --git a/src/support/src/Testing/Fakes/EventFake.php b/src/support/src/Testing/Fakes/EventFake.php index 6f71457a15..c5c739fcb3 100644 --- a/src/support/src/Testing/Fakes/EventFake.php +++ b/src/support/src/Testing/Fakes/EventFake.php @@ -55,6 +55,8 @@ public function __construct(DispatcherContract $dispatcher, array|string $events /** * Specify the events that should be dispatched instead of faked. + * + * @return $this */ public function except(array|string $eventsToDispatch): static { diff --git a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php index 118e553974..3d91adbd68 100644 --- a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php +++ b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php @@ -215,6 +215,8 @@ public function afterResponse(callable $callback): void /** * Throw exceptions when they are reported. + * + * @return $this */ public function throwOnReport(): static { @@ -226,6 +228,8 @@ public function throwOnReport(): static /** * Throw the first reported exception. * + * @return $this + * * @throws Throwable */ public function throwFirstReported(): static @@ -249,6 +253,8 @@ public function reported(): array /** * Set the "original" handler that should be used by the fake. + * + * @return $this */ public function setHandler(ExceptionHandler $handler): static { diff --git a/src/support/src/Testing/Fakes/MailFake.php b/src/support/src/Testing/Fakes/MailFake.php index 62cdd984b3..392fefc197 100644 --- a/src/support/src/Testing/Fakes/MailFake.php +++ b/src/support/src/Testing/Fakes/MailFake.php @@ -524,7 +524,7 @@ public function queue(array|Mailable|string $view, UnitEnum|string|null $queue = if ($queue !== null) { // Queueable owns identifier normalization, so it is intentionally absent from the Mailable contract. - $view->onQueue($queue); // @phpstan-ignore method.notFound + $view->onQueue($queue); // @phpstan-ignore method.notFound (Queueable method outside the Mailable contract) } $this->queuedMailables[] = $view; @@ -591,6 +591,8 @@ protected function pullCurrentMailer(): string /** * Forget all of the resolved mailer instances. + * + * @return $this */ public function forgetMailers(): static { diff --git a/src/support/src/Testing/Fakes/NotificationFake.php b/src/support/src/Testing/Fakes/NotificationFake.php index 0795a3aa41..2b27daa918 100644 --- a/src/support/src/Testing/Fakes/NotificationFake.php +++ b/src/support/src/Testing/Fakes/NotificationFake.php @@ -358,6 +358,8 @@ public function channel(UnitEnum|string|null $name = null): mixed /** * Set the locale of notifications. + * + * @return $this */ public function locale(string $locale): static { @@ -368,6 +370,8 @@ public function locale(string $locale): static /** * Specify if notification should be serialized and restored when being "pushed" to the queue. + * + * @return $this */ public function serializeAndRestore(bool $serializeAndRestore = true): static { diff --git a/src/support/src/Testing/Fakes/PendingChainFake.php b/src/support/src/Testing/Fakes/PendingChainFake.php index 1dd54c86d0..b5348e41c5 100644 --- a/src/support/src/Testing/Fakes/PendingChainFake.php +++ b/src/support/src/Testing/Fakes/PendingChainFake.php @@ -4,9 +4,7 @@ namespace Hypervel\Support\Testing\Fakes; -use Closure; use Hypervel\Foundation\Bus\PendingChain; -use Hypervel\Queue\CallQueuedClosure; class PendingChainFake extends PendingChain { @@ -27,20 +25,6 @@ public function __construct( */ public function dispatch(mixed ...$arguments): mixed { - if (is_string($this->job)) { - $firstJob = new $this->job(...$arguments); - } elseif ($this->job instanceof Closure) { - $firstJob = CallQueuedClosure::create($this->job); - } else { - $firstJob = $this->job; - } - - $firstJob->allOnConnection($this->connection); - $firstJob->allOnQueue($this->queue); - $firstJob->chain($this->chain); - $firstJob->delay($this->delay); - $firstJob->chainCatchCallbacks = $this->catchCallbacks(); - - return $this->bus->dispatch($firstJob); + return $this->bus->dispatch($this->prepareFirstJob($arguments)); } } diff --git a/src/support/src/Testing/Fakes/QueueConnectionFake.php b/src/support/src/Testing/Fakes/QueueConnectionFake.php new file mode 100644 index 0000000000..8c465f3aa6 --- /dev/null +++ b/src/support/src/Testing/Fakes/QueueConnectionFake.php @@ -0,0 +1,160 @@ +fake->size($queue); + } + + /** + * Get the number of pending jobs. + */ + public function pendingSize(UnitEnum|string|null $queue = null): int + { + return $this->fake->pendingSize($queue); + } + + /** + * Get the number of delayed jobs. + */ + public function delayedSize(UnitEnum|string|null $queue = null): int + { + return $this->fake->delayedSize($queue); + } + + /** + * Get the number of reserved jobs. + */ + public function reservedSize(UnitEnum|string|null $queue = null): int + { + return $this->fake->reservedSize($queue); + } + + /** + * Get the creation timestamp of the oldest pending job, excluding delayed jobs. + */ + public function creationTimeOfOldestPendingJob(UnitEnum|string|null $queue = null): ?int + { + return $this->fake->creationTimeOfOldestPendingJob($queue); + } + + /** + * Push a new job onto the queue. + */ + public function push(object|string $job, mixed $data = '', UnitEnum|string|null $queue = null): mixed + { + return $this->fake->pushToConnection($this->connection, $job, $data, $queue); + } + + /** + * Push a new job onto the queue. + */ + public function pushOn(UnitEnum|string|null $queue, object|string $job, mixed $data = ''): mixed + { + return $this->push($job, $data, $queue); + } + + /** + * Push a raw payload onto the queue. + */ + public function pushRaw(string $payload, UnitEnum|string|null $queue = null, array $options = []): mixed + { + return $this->fake->pushRaw($payload, $queue, $options); + } + + /** + * Push a new job onto the queue after (n) seconds. + */ + public function later( + DateInterval|DateTimeInterface|int $delay, + object|string $job, + mixed $data = '', + UnitEnum|string|null $queue = null, + ): mixed { + return $this->fake->pushToConnection($this->connection, $job, $data, $queue, $delay); + } + + /** + * Push a new job onto a specific queue after (n) seconds. + */ + public function laterOn( + UnitEnum|string|null $queue, + DateInterval|DateTimeInterface|int $delay, + object|string $job, + mixed $data = '', + ): mixed { + return $this->later($delay, $job, $data, $queue); + } + + /** + * Push an array of jobs onto the queue. + */ + public function bulk(array $jobs, mixed $data = '', UnitEnum|string|null $queue = null): mixed + { + return $this->fake->bulkToConnection($this->connection, $jobs, $data, $queue); + } + + /** + * Pop the next job off of the queue. + */ + public function pop(UnitEnum|string|null $queue = null): ?Job + { + return $this->fake->pop($queue); + } + + /** + * Get the connection name for the queue. + */ + public function getConnectionName(): string + { + return $this->fake->getConnectionName(); + } + + /** + * Set the connection name for the queue. + * + * @return $this + */ + public function setConnectionName(string $name): static + { + return $this; + } + + /** + * Forward other calls to the queue fake. + */ + public function __call(string $method, array $parameters): mixed + { + $result = $this->fake->{$method}(...$parameters); + + return $result === $this->fake ? $this : $result; + } +} diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php index fe5e6bea42..3b4bd5ad87 100644 --- a/src/support/src/Testing/Fakes/QueueFake.php +++ b/src/support/src/Testing/Fakes/QueueFake.php @@ -14,6 +14,7 @@ use Hypervel\Contracts\Queue\Job; use Hypervel\Contracts\Queue\Queue; use Hypervel\Events\CallQueuedListener; +use Hypervel\Queue\Attributes\Connection; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\ReadsQueueAttributes; use Hypervel\Queue\CallQueuedClosure; @@ -110,6 +111,8 @@ public function __construct(Container $app, array|string $jobsToFake = [], ?Fact /** * Specify the jobs that should be queued instead of faked. + * + * @return $this */ public function except(array|string $jobsToBeQueued): static { @@ -399,9 +402,14 @@ public function hasPushed(string $job): bool /** * Resolve a queue connection instance. */ - public function connection(mixed $value = null): Queue + public function connection(UnitEnum|string|null $value = null): Queue { - return $this; + if ($value === null) { + return $this; + } + + // Dispatchers select the connection here, and a job that is not faked must still reach it. + return new QueueConnectionFake($this, $value instanceof UnitEnum ? (string) enum_value($value) : $value); } /** @@ -589,17 +597,22 @@ public function creationTimeOfOldestPendingJob(UnitEnum|string|null $queue = nul */ public function push(object|string $job, mixed $data = '', UnitEnum|string|null $queue = null): mixed { - return $this->enqueueUsing($job, $data, $queue, null); + return $this->pushToConnection(null, $job, $data, $queue); } /** - * Push a job through the fake or its underlying queue. + * Push a job onto the fake, or onto the given connection when the job is not faked. + * + * Without a connection, a job that is not faked goes to its own connection, else the default connection. + * + * @internal */ - protected function enqueueUsing( + public function pushToConnection( + ?string $connection, object|string $job, - mixed $data, - UnitEnum|string|null $queue, - DateInterval|DateTimeInterface|int|null $delay, + mixed $data = '', + UnitEnum|string|null $queue = null, + DateInterval|DateTimeInterface|int|null $delay = null, ): mixed { $queue = $this->normalizeQueue($queue); @@ -632,11 +645,9 @@ protected function enqueueUsing( $result = null; } else { - $connection = is_object($job) && isset($job->connection) - ? $job->connection - : null; - - $queueConnection = $this->queue->connection($connection); + $queueConnection = $this->queue->connection( + $connection ?? (is_object($job) ? $this->getAttributeValue($job, Connection::class, 'connection') : null) + ); $result = $delay === null ? $queueConnection->push($job, $data, $queue) : $queueConnection->later($delay, $job, $data, $queue); @@ -704,7 +715,7 @@ public function later( mixed $data = '', UnitEnum|string|null $queue = null, ): mixed { - return $this->enqueueUsing($job, $data, $queue, $delay); + return $this->pushToConnection(null, $job, $data, $queue, $delay); } /** @@ -759,14 +770,24 @@ public function pop(UnitEnum|string|null $queue = null): ?Job */ public function bulk(array $jobs, mixed $data = '', UnitEnum|string|null $queue = null): mixed { - foreach ($jobs as $job) { - $delay = is_object($job) ? $this->getAttributeValue($job, Delay::class, 'delay') : null; + return $this->bulkToConnection(null, $jobs, $data, $queue); + } - if ($delay !== null) { - $this->later($delay, $job, $data, $queue); - } else { - $this->push($job, $data, $queue); - } + /** + * Push an array of jobs onto the fake, or onto the given connection for jobs that are not faked. + * + * @internal + */ + public function bulkToConnection(?string $connection, array $jobs, mixed $data = '', UnitEnum|string|null $queue = null): mixed + { + foreach ($jobs as $job) { + $this->pushToConnection( + $connection, + $job, + $data, + $queue, + is_object($job) ? $this->getAttributeValue($job, Delay::class, 'delay') : null, + ); } return null; @@ -792,6 +813,8 @@ public function rawPushes(): array /** * Specify if jobs should be serialized and restored when being "pushed" to the queue. + * + * @return $this */ public function serializeAndRestore(bool $serializeAndRestore = true): static { @@ -830,6 +853,8 @@ public function clearReserved(): void /** * Register a callback to be invoked before pushing a job. + * + * @return $this */ public function beforePushing(callable $callback): static { @@ -840,6 +865,8 @@ public function beforePushing(callable $callback): static /** * Register a callback to be invoked after pushing a job. + * + * @return $this */ public function afterPushing(callable $callback): static { @@ -866,6 +893,8 @@ public function getConnectionName(): string /** * Set the connection name for the queue. + * + * @return $this */ public function setConnectionName(string $name): static { diff --git a/src/support/src/Timebox.php b/src/support/src/Timebox.php index c3e0b001ad..9a0b89960c 100644 --- a/src/support/src/Timebox.php +++ b/src/support/src/Timebox.php @@ -55,6 +55,8 @@ public function call(callable $callback, int $microseconds): mixed /** * Indicate that the timebox can return early. + * + * @return $this */ public function returnEarly(): static { @@ -65,6 +67,8 @@ public function returnEarly(): static /** * Indicate that the timebox cannot return early. + * + * @return $this */ public function dontReturnEarly(): static { diff --git a/src/support/src/Traits/Dumpable.php b/src/support/src/Traits/Dumpable.php index a77ceab177..4430994519 100644 --- a/src/support/src/Traits/Dumpable.php +++ b/src/support/src/Traits/Dumpable.php @@ -16,6 +16,8 @@ public function dd(mixed ...$args): never /** * Dump the given arguments. + * + * @return $this */ public function dump(mixed ...$args): static { diff --git a/src/support/src/Traits/GuardsProcessOutput.php b/src/support/src/Traits/GuardsProcessOutput.php index 9d18c2c209..fa99699bd6 100644 --- a/src/support/src/Traits/GuardsProcessOutput.php +++ b/src/support/src/Traits/GuardsProcessOutput.php @@ -6,7 +6,6 @@ use Closure; use Hypervel\Coroutine\Coroutine; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Process\Process; use Throwable; @@ -34,7 +33,7 @@ protected function guardProcessOutput(Process $process, ?callable $output): ?Clo return $output($type, $buffer); } catch (Throwable $exception) { $failed = true; - $cancellation = null; + $failure = $exception; // Coroutine teardown can expose an invalid native resource through SWOOLE_HOOK_PROC. // Retry failed cleanup while preserving the callback failure or cancellation. @@ -43,17 +42,12 @@ protected function guardProcessOutput(Process $process, ?callable $output): ?Clo $process->stop(0); break; - } catch (CanceledException $cleanupException) { - $cancellation ??= $cleanupException; - } catch (Throwable) { + } catch (Throwable $cleanupException) { + $failure = Coroutine::preferCancellation($failure, $cleanupException); } } - if ($cancellation !== null) { - throw Coroutine::preferCancellation($exception, $cancellation); - } - - throw $exception; + throw $failure; } }; } diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index 9b32d2c8b3..3dd2fe92e5 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -76,10 +76,10 @@ public function hasAny(array|string $keys): bool /** * Apply the callback if the instance contains the given key. * - * @template TReturn + * @template TReturn = never * @template TReturnDefault = never * - * @param callable(mixed): TReturn $callback + * @param callable(mixed): (null|TReturn|void) $callback * @param null|(callable(): TReturnDefault) $default * @return $this|TReturn|TReturnDefault */ @@ -150,10 +150,10 @@ public function anyFilled(array|string $keys): bool /** * Apply the callback if the instance contains a non-empty value for the given key. * - * @template TReturn + * @template TReturn = never * @template TReturnDefault = never * - * @param callable(mixed): TReturn $callback + * @param callable(mixed): (null|TReturn|void) $callback * @param null|(callable(): TReturnDefault) $default * @return $this|TReturn|TReturnDefault */ @@ -174,11 +174,11 @@ public function whenFilled(string $key, callable $callback, ?callable $default = * Apply the callback if the instance contains a valid enum value for the given key. * * @template TEnum of BackedEnum - * @template TReturn + * @template TReturn = never * @template TReturnDefault = never * * @param class-string $enumClass - * @param callable(TEnum): TReturn $callback + * @param callable(TEnum): (null|TReturn|void) $callback * @param null|(callable(): TReturnDefault) $default * @return $this|TReturn|TReturnDefault */ @@ -208,10 +208,10 @@ public function missing(array|string $key): bool /** * Apply the callback if the instance is missing the given key. * - * @template TReturn + * @template TReturn = never * @template TReturnDefault = never * - * @param callable(mixed): TReturn $callback + * @param callable(mixed): (null|TReturn|void) $callback * @param null|(callable(): TReturnDefault) $default * @return $this|TReturn|TReturnDefault */ @@ -404,7 +404,7 @@ public function interval(string $key, Unit|string|null $unit = null): ?CarbonInt * @template TDefault of TEnum|null * * @param class-string $enumClass - * @param TDefault $default + * @param (Closure(): TDefault)|TDefault $default * @return TDefault|TEnum */ public function enum(string $key, string $enumClass, mixed $default = null): mixed diff --git a/src/support/src/Uri.php b/src/support/src/Uri.php index 7a155f4773..1704e4c409 100644 --- a/src/support/src/Uri.php +++ b/src/support/src/Uri.php @@ -412,6 +412,8 @@ public function isNotEmpty(): bool /** * Dump the string representation of the URI. + * + * @return $this */ public function dump(mixed ...$args): static { diff --git a/src/support/src/ValidatedInput.php b/src/support/src/ValidatedInput.php index 3d7f7d068f..235ae224f2 100644 --- a/src/support/src/ValidatedInput.php +++ b/src/support/src/ValidatedInput.php @@ -92,6 +92,8 @@ public function file(string $key, mixed $default = null): mixed /** * Dump the items. + * + * @return $this */ public function dump(mixed ...$keys): static { diff --git a/src/support/src/ViewErrorBag.php b/src/support/src/ViewErrorBag.php index 8e270125c6..b371d2137b 100644 --- a/src/support/src/ViewErrorBag.php +++ b/src/support/src/ViewErrorBag.php @@ -49,6 +49,8 @@ public function getBags(): array /** * Add a new MessageBag instance to the bags. + * + * @return $this */ public function put(string $key, MessageBagContract $bag): static { diff --git a/src/support/src/helpers.php b/src/support/src/helpers.php index ea54cf4a4a..ed216041c5 100644 --- a/src/support/src/helpers.php +++ b/src/support/src/helpers.php @@ -140,10 +140,16 @@ function e(\BackedEnum|DeferringDisplayableValue|\Stringable|float|Htmlable|int| if (! function_exists('env')) { /** - * Gets the value of an environment variable. + * Get the value of an environment variable. + * + * @template TDefault + * + * @param (Closure(): TDefault)|TDefault $default + * @return null|bool|string|TDefault */ function env(string $key, mixed $default = null): mixed { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (env() is the helper for Env::get()) return Env::get($key, $default); } } @@ -157,6 +163,7 @@ function env(string $key, mixed $default = null): mixed */ function env_array(string $key, array $default = []): array { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (env_array() is the helper for Env::getArray()) return Env::getArray($key, $default); } } @@ -169,6 +176,7 @@ function env_array(string $key, array $default = []): array */ function env_or_fail(string $key): mixed { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (env_or_fail() is the helper for Env::getOrFail()) return Env::getOrFail($key); } } @@ -276,8 +284,8 @@ function once(callable $callback) * @template TReturn * * @param TValue $value - * @param null|(callable(TValue): TReturn) $callback - * @return ($callback is null ? Optional : ($value is null ? null : TReturn)) + * @param null|(callable((TValue is null ? never : TValue)): TReturn) $callback + * @return ($callback is null ? Optional : ($value is null ? null : TReturn)) */ function optional($value = null, ?callable $callback = null) { @@ -509,9 +517,9 @@ function trait_uses_recursive($trait): array * @template TReturn * @template TDefault * - * @param TValue $value + * @param null|TValue $value * @param callable(TValue): TReturn $callback - * @param callable(TValue): TDefault|TDefault $default + * @param callable(null|TValue): TDefault|TDefault $default * @return ($value is empty ? TDefault : TReturn) */ function transform($value, callable $callback, $default = null) diff --git a/src/telescope/composer.json b/src/telescope/composer.json index b4654affec..21626acc1c 100644 --- a/src/telescope/composer.json +++ b/src/telescope/composer.json @@ -50,6 +50,7 @@ "hypervel/notifications": "^0.4", "hypervel/queue": "^0.4", "hypervel/redis": "^0.4", + "hypervel/reflection": "^0.4", "hypervel/sentinel": "^0.4", "hypervel/support": "^0.4", "hypervel/view": "^0.4", @@ -57,8 +58,8 @@ "psr/http-message": "^2.0", "psr/log": "^3.0", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11", "symfony/mime": "^8.1", "symfony/var-dumper": "^8.1" }, diff --git a/src/telescope/src/EntryResult.php b/src/telescope/src/EntryResult.php index af7ce40d4c..dfc98e4d6f 100644 --- a/src/telescope/src/EntryResult.php +++ b/src/telescope/src/EntryResult.php @@ -41,6 +41,8 @@ public function __construct( /** * Set the URL to the entry user's avatar. + * + * @return $this */ public function generateAvatar(): static { diff --git a/src/telescope/src/EntryUpdate.php b/src/telescope/src/EntryUpdate.php index ce9a2bcbea..ffd7b17aba 100644 --- a/src/telescope/src/EntryUpdate.php +++ b/src/telescope/src/EntryUpdate.php @@ -35,6 +35,8 @@ public static function make(mixed ...$arguments): static /** * Set the properties that should be updated. + * + * @return $this */ public function change(array $changes): static { @@ -45,6 +47,8 @@ public function change(array $changes): static /** * Add tags to the entry. + * + * @return $this */ public function addTags(array $tags): static { @@ -57,6 +61,8 @@ public function addTags(array $tags): static /** * Remove tags from the entry. + * + * @return $this */ public function removeTags(array $tags): static { diff --git a/src/telescope/src/ExtractProperties.php b/src/telescope/src/ExtractProperties.php index db69020bf3..0c5e459563 100644 --- a/src/telescope/src/ExtractProperties.php +++ b/src/telescope/src/ExtractProperties.php @@ -5,8 +5,8 @@ namespace Hypervel\Telescope; use Hypervel\Database\Eloquent\Model; +use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\Collection; -use ReflectionClass; class ExtractProperties { @@ -17,19 +17,28 @@ class ExtractProperties */ public static function from(mixed $target): array { + $names = []; + // Native encoding captures reflected state instead of an object's published representation. - return Collection::make((new ReflectionClass($target))->getProperties()) - ->mapWithKeys(function ($property) use ($target) { + // Unlike Laravel, a parent's private properties are extracted too. One whose name an earlier + // property already uses is keyed by its declaring class. + return Collection::make(ClassMetadataCache::allProperties($target)) + ->mapWithKeys(function ($property) use ($target, &$names) { if (! $property->isInitialized($target)) { return []; } + $name = isset($names[$property->getName()]) + ? $property->class . '::$' . $property->getName() + : $property->getName(); + $names[$property->getName()] = true; + if (($value = $property->getValue($target)) instanceof Model) { - return [$property->getName() => FormatModel::given($value)]; + return [$name => FormatModel::given($value)]; } if (is_object($value)) { return [ - $property->getName() => [ + $name => [ 'class' => get_class($value), 'properties' => method_exists($value, 'formatForTelescope') ? $value->formatForTelescope() @@ -37,7 +46,7 @@ public static function from(mixed $target): array ], ]; } - return [$property->getName() => JsonNormalizer::normalize($value)]; + return [$name => JsonNormalizer::normalize($value)]; })->toArray(); } } diff --git a/src/telescope/src/ExtractTags.php b/src/telescope/src/ExtractTags.php index 583279559c..f8533bb1f1 100644 --- a/src/telescope/src/ExtractTags.php +++ b/src/telescope/src/ExtractTags.php @@ -9,6 +9,7 @@ use Hypervel\Events\CallQueuedListener; use Hypervel\Mail\SendQueuedMailable; use Hypervel\Notifications\SendQueuedNotifications; +use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\Collection; use ReflectionClass; use ReflectionException; @@ -113,7 +114,8 @@ protected static function targetsFor(mixed $job): array protected static function modelsFor(array $targets): Collection { return Collection::make($targets)->map(function ($target) { - return Collection::make((new ReflectionClass($target))->getProperties())->map(function ($property) use ($target) { + // Unlike Laravel, a parent's private properties are read too, since the target holds them. + return Collection::make(ClassMetadataCache::allProperties($target))->map(function ($property) use ($target) { if (! is_object($target) || $property->isInitialized($target)) { return static::resolveValue($property->getValue($target)); } diff --git a/src/telescope/src/IncomingEntry.php b/src/telescope/src/IncomingEntry.php index 8e22dc08f0..d0e8e6a2b2 100644 --- a/src/telescope/src/IncomingEntry.php +++ b/src/telescope/src/IncomingEntry.php @@ -74,6 +74,8 @@ public static function make(mixed ...$arguments): static /** * Assign the entry a given batch ID. + * + * @return $this */ public function batchId(string $batchId): static { @@ -84,6 +86,8 @@ public function batchId(string $batchId): static /** * Assign the entry a given type. + * + * @return $this */ public function type(string $type): static { @@ -94,6 +98,8 @@ public function type(string $type): static /** * Assign the entry a family hash. + * + * @return $this */ public function withFamilyHash(?string $familyHash): static { @@ -104,6 +110,8 @@ public function withFamilyHash(?string $familyHash): static /** * Set the currently authenticated user. + * + * @return $this */ public function user(Authenticatable $user): static { @@ -124,6 +132,8 @@ public function user(Authenticatable $user): static /** * Merge tags into the entry's existing tags. + * + * @return $this */ public function tags(array $tags): static { diff --git a/src/telescope/src/Storage/DatabaseEntriesRepository.php b/src/telescope/src/Storage/DatabaseEntriesRepository.php index 03c0b42565..c9cc0ba7b8 100644 --- a/src/telescope/src/Storage/DatabaseEntriesRepository.php +++ b/src/telescope/src/Storage/DatabaseEntriesRepository.php @@ -223,7 +223,7 @@ protected function storeExceptions(Collection $exceptions): void array_merge($exception->content, ['occurrences' => $occurrences[$familyHash]]) ), ]); - })->toArray(); + })->all(); $connection = DB::connection($this->connection); @@ -328,7 +328,7 @@ protected function updateTags(EntryUpdate $entry): void 'entry_uuid' => $entry->uuid, 'tag' => $tag, ]; - })->toArray() + })->all() ); } catch (UniqueConstraintViolationException) { // Ignore tags that already exist... diff --git a/src/telescope/src/Storage/EntryModel.php b/src/telescope/src/Storage/EntryModel.php index f57ba228ec..846a131d36 100644 --- a/src/telescope/src/Storage/EntryModel.php +++ b/src/telescope/src/Storage/EntryModel.php @@ -28,6 +28,7 @@ class EntryModel extends Model /** * The table associated with the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (a subclass's own #[Table] would drop this table name) protected ?string $table = 'telescope_entries'; /** @@ -45,16 +46,19 @@ class EntryModel extends Model /** * The primary key for the model. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Table] would override a subclass's $primaryKey = 'id') protected ?string $primaryKey = 'uuid'; /** * The "type" of the auto-incrementing ID. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[Table] would override a subclass's $keyType = 'int') protected string $keyType = 'string'; /** * Prevent Eloquent from overriding uuid with `lastInsertId`. */ + // @phpstan-ignore hypervel.legacyModelProperty (#[WithoutIncrementing] would override a subclass's $incrementing = true) public bool $incrementing = false; /** @@ -63,6 +67,7 @@ class EntryModel extends Model * @param Builder $query * @return Builder */ + // @phpstan-ignore hypervel.legacyModelScope (public API, kept as Telescope declares it) public function scopeWithTelescopeOptions(Builder $query, ?string $type, EntryQueryOptions $options): Builder { $this->whereType($query, $type) @@ -78,6 +83,8 @@ public function scopeWithTelescopeOptions(Builder $query, ?string $type, EntryQu /** * Scope the query for the given type. + * + * @return $this */ protected function whereType(Builder $query, ?string $type): static { @@ -90,6 +97,8 @@ protected function whereType(Builder $query, ?string $type): static /** * Scope the query for the given batch ID. + * + * @return $this */ protected function whereBatchId(Builder $query, EntryQueryOptions $options): static { @@ -102,6 +111,8 @@ protected function whereBatchId(Builder $query, EntryQueryOptions $options): sta /** * Scope the query for the given entry UUIDs. + * + * @return $this */ protected function whereUuids(Builder $query, EntryQueryOptions $options): static { @@ -114,6 +125,8 @@ protected function whereUuids(Builder $query, EntryQueryOptions $options): stati /** * Scope the query for the given tag. + * + * @return $this */ protected function whereTag(Builder $query, EntryQueryOptions $options): static { @@ -134,6 +147,8 @@ protected function whereTag(Builder $query, EntryQueryOptions $options): static /** * Scope the query for the given family hash. + * + * @return $this */ protected function whereFamilyHash(Builder $query, EntryQueryOptions $options): static { @@ -146,6 +161,8 @@ protected function whereFamilyHash(Builder $query, EntryQueryOptions $options): /** * Scope the query for the given pagination options. + * + * @return $this */ protected function whereBeforeSequence(Builder $query, EntryQueryOptions $options): static { @@ -158,6 +175,8 @@ protected function whereBeforeSequence(Builder $query, EntryQueryOptions $option /** * Scope the query for the given display options. + * + * @return $this */ protected function filter(Builder $query, EntryQueryOptions $options): static { diff --git a/src/telescope/src/Storage/EntryQueryOptions.php b/src/telescope/src/Storage/EntryQueryOptions.php index c938be525d..1f822e3496 100644 --- a/src/telescope/src/Storage/EntryQueryOptions.php +++ b/src/telescope/src/Storage/EntryQueryOptions.php @@ -49,12 +49,12 @@ class EntryQueryOptions public static function fromRequest(Request $request): static { return (new static) - ->batchId($request->batch_id) // @phpstan-ignore property.notFound - ->uuids($request->uuids) // @phpstan-ignore property.notFound - ->beforeSequence($request->before) // @phpstan-ignore property.notFound - ->tag($request->tag) // @phpstan-ignore property.notFound + ->batchId($request->batch_id) + ->uuids($request->uuids) + ->beforeSequence($request->before) + ->tag($request->tag) ->exactTag($request->boolean('exact_tag')) - ->familyHash($request->family_hash) // @phpstan-ignore property.notFound + ->familyHash($request->family_hash) ->limit((int) ($request->take ?? 50)); } @@ -68,6 +68,8 @@ public static function forBatchId(?string $batchId): static /** * Set the batch ID for the query. + * + * @return $this */ public function batchId(?string $batchId): static { @@ -78,6 +80,8 @@ public function batchId(?string $batchId): static /** * Set the list of UUIDs of entries to retrieve. + * + * @return $this */ public function uuids(?array $uuids): static { @@ -88,6 +92,8 @@ public function uuids(?array $uuids): static /** * Set the ID that all retrieved entries should be less than. + * + * @return $this */ public function beforeSequence(int|string|null $id): static { @@ -98,6 +104,8 @@ public function beforeSequence(int|string|null $id): static /** * Set the tag that must belong to retrieved entries. + * + * @return $this */ public function tag(?string $tag): static { @@ -108,6 +116,8 @@ public function tag(?string $tag): static /** * Match the tag literally instead of as a search expression. + * + * @return $this */ public function exactTag(bool $exactTag = true): static { @@ -118,6 +128,8 @@ public function exactTag(bool $exactTag = true): static /** * Set the family hash that must belong to retrieved entries. + * + * @return $this */ public function familyHash(?string $familyHash): static { @@ -128,6 +140,8 @@ public function familyHash(?string $familyHash): static /** * Set the number of entries that should be retrieved. + * + * @return $this */ public function limit(int $limit): static { diff --git a/src/telescope/src/Watchers/EventWatcher.php b/src/telescope/src/Watchers/EventWatcher.php index 7020c777e9..98d638998d 100644 --- a/src/telescope/src/Watchers/EventWatcher.php +++ b/src/telescope/src/Watchers/EventWatcher.php @@ -73,7 +73,7 @@ protected function extractPayload(string $event, array $payload): array 'class' => get_class($value), 'properties' => JsonNormalizer::normalize($value), ] : $value; - })->toArray(); + })->all(); } /** @@ -112,7 +112,7 @@ protected function formatListeners(string $eventName): array 'name' => $listener, 'queued' => $queued ?? false, ]; - })->values()->toArray(); + })->values()->all(); } /** diff --git a/src/telescope/src/Watchers/ExceptionWatcher.php b/src/telescope/src/Watchers/ExceptionWatcher.php index ea6098aea4..0994f81280 100644 --- a/src/telescope/src/Watchers/ExceptionWatcher.php +++ b/src/telescope/src/Watchers/ExceptionWatcher.php @@ -39,7 +39,7 @@ public function recordException(MessageLogged $event): void $trace = Collection::make($exception->getTrace())->map(function ($item) { return Arr::only($item, ['file', 'line']); - })->toArray(); + })->all(); $content = [ 'class' => get_class($exception), diff --git a/src/telescope/src/Watchers/JobWatcher.php b/src/telescope/src/Watchers/JobWatcher.php index e6fffa7200..421e8993ab 100644 --- a/src/telescope/src/Watchers/JobWatcher.php +++ b/src/telescope/src/Watchers/JobWatcher.php @@ -44,7 +44,7 @@ class JobWatcher extends Watcher public function register(Application $app): void { Queue::createPayloadUsing(function ($connection, $queue, $payload) { - return ['telescope_uuid' => optional($this->recordJob($connection, $queue, $payload))->uuid]; // @phpstan-ignore property.notFound (optional() __get proxy) + return ['telescope_uuid' => optional($this->recordJob($connection, $queue, $payload))->uuid]; }); $app->make(Dispatcher::class) diff --git a/src/telescope/src/Watchers/ViewWatcher.php b/src/telescope/src/Watchers/ViewWatcher.php index e83f028173..ae2062145b 100644 --- a/src/telescope/src/Watchers/ViewWatcher.php +++ b/src/telescope/src/Watchers/ViewWatcher.php @@ -74,7 +74,7 @@ protected function extractKeysFromData(ViewContract $view): array { return Collection::make($view->getData())->filter(function ($value, $key) { return ! in_array($key, ['app', '__env', 'obLevel', 'errors'], true); - })->keys()->toArray(); + })->keys()->all(); } /** diff --git a/src/testbench/bin/testbench b/src/testbench/bin/testbench index 633e8a3d9a..fea0226950 100755 --- a/src/testbench/bin/testbench +++ b/src/testbench/bin/testbench @@ -8,8 +8,9 @@ use Composer\InstalledVersions; // Load autoloader - check multiple locations for monorepo vs installed package $autoloadPaths = [ $_composer_autoload_path ?? null, + getenv('HYPERVEL_AUTOLOAD_PATH') ?: null, // remote(): the consumer package's autoloader __DIR__ . '/../../../vendor/autoload.php', // monorepo: src/testbench/bin -> components/vendor - __DIR__ . '/../../vendor/autoload.php', // installed: testbench/bin -> testbench/vendor + __DIR__ . '/../vendor/autoload.php', // split checkout: testbench/bin -> testbench/vendor ]; foreach (array_filter($autoloadPaths) as $autoloadPath) { diff --git a/src/testbench/composer.json b/src/testbench/composer.json index 772cd4b0bd..46ab522d50 100644 --- a/src/testbench/composer.json +++ b/src/testbench/composer.json @@ -36,10 +36,12 @@ "hypervel/concurrency": "^0.4", "hypervel/config": "^0.4", "hypervel/console": "^0.4", + "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/coordinator": "^0.4", "hypervel/core": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/di": "^0.4", "hypervel/filesystem": "^0.4", diff --git a/src/testbench/hypervel/artisan b/src/testbench/hypervel/artisan index 3d108b03f5..33f9c878e8 100644 --- a/src/testbench/hypervel/artisan +++ b/src/testbench/hypervel/artisan @@ -6,7 +6,6 @@ declare(strict_types=1); use Hypervel\Console\Application as ConsoleApplication; use Hypervel\Contracts\Console\Kernel as ConsoleKernelContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Foundation\Application; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; @@ -18,26 +17,7 @@ error_reporting(E_ALL); ! defined('BASE_PATH') && define('BASE_PATH', __DIR__); -$autoloadPaths = array_filter([ - getenv('HYPERVEL_AUTOLOAD_PATH') ?: null, - BASE_PATH . '/vendor/autoload.php', - getcwd() . '/vendor/autoload.php', - is_string(getenv('TESTBENCH_WORKING_PATH')) - ? (string) getenv('TESTBENCH_WORKING_PATH') . '/vendor/autoload.php' - : null, -]); - -foreach ($autoloadPaths as $autoloadPath) { - if (is_file($autoloadPath)) { - require $autoloadPath; - break; - } -} - -if (! class_exists(Application::class)) { - fwrite(STDERR, "Unable to locate Composer autoloader for the Hypervel testbench workbench.\n"); - exit(1); -} +require __DIR__ . '/bootstrap/autoload.php'; $committedTestbenchRoot = realpath(__DIR__ . '/..'); diff --git a/src/testbench/hypervel/bootstrap/autoload.php b/src/testbench/hypervel/bootstrap/autoload.php index d8291beca4..b78447e363 100644 --- a/src/testbench/hypervel/bootstrap/autoload.php +++ b/src/testbench/hypervel/bootstrap/autoload.php @@ -2,10 +2,27 @@ declare(strict_types=1); -if (! defined('TESTBENCH_WORKING_PATH') && is_string(getenv('TESTBENCH_WORKING_PATH'))) { - define('TESTBENCH_WORKING_PATH', getenv('TESTBENCH_WORKING_PATH')); -} +use Hypervel\Foundation\Application; + +// Cache commands pass the application's autoloader explicitly. A runtime copy links the +// package's vendor directory beside the application. +$autoloadPaths = array_filter([ + getenv('HYPERVEL_AUTOLOAD_PATH') ?: null, + dirname(__DIR__) . '/vendor/autoload.php', + getcwd() . '/vendor/autoload.php', + is_string(getenv('TESTBENCH_WORKING_PATH')) + ? (string) getenv('TESTBENCH_WORKING_PATH') . '/vendor/autoload.php' + : null, +]); -$workingPath = defined('TESTBENCH_WORKING_PATH') ? TESTBENCH_WORKING_PATH : realpath(__DIR__ . '/../'); +foreach ($autoloadPaths as $autoloadPath) { + if (is_file($autoloadPath)) { + require $autoloadPath; + break; + } +} -require $workingPath . '/vendor/autoload.php'; +if (! class_exists(Application::class)) { + fwrite(STDERR, "Unable to locate Composer autoloader for the Hypervel testbench workbench.\n"); + exit(1); +} diff --git a/src/testbench/src/Attributes/UsesVendor.php b/src/testbench/src/Attributes/UsesVendor.php index 733c6a66f0..d4667e2dcd 100644 --- a/src/testbench/src/Attributes/UsesVendor.php +++ b/src/testbench/src/Attributes/UsesVendor.php @@ -6,6 +6,7 @@ use Attribute; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Support\Composer; use Hypervel\Testbench\Contracts\Attributes\AfterEach; use Hypervel\Testbench\Contracts\Attributes\BeforeEach; use Hypervel\Testbench\Foundation\Actions\CreateVendorSymlink; @@ -28,7 +29,7 @@ public function beforeEach(ApplicationContract $app): void { $hypervel = clone $app; - (new CreateVendorSymlink(package_path('vendor')))->handle($hypervel); + (new CreateVendorSymlink(Composer::vendorPath(package_path())))->handle($hypervel); $this->vendorSymlinkCreated = $hypervel->make('TESTBENCH_VENDOR_SYMLINK'); } diff --git a/src/testbench/src/Bootstrap/HandleExceptions.php b/src/testbench/src/Bootstrap/HandleExceptions.php index 1d355f83a8..1c68e8f485 100644 --- a/src/testbench/src/Bootstrap/HandleExceptions.php +++ b/src/testbench/src/Bootstrap/HandleExceptions.php @@ -29,6 +29,7 @@ public function handleDeprecationError(string $message, string $file, int $line, parent::handleDeprecationError($message, $file, $line, $level); }, report: false); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the test runner sets this for the test process) $testbenchConvertDeprecationsToExceptions = (bool) Env::get( 'TESTBENCH_CONVERT_DEPRECATIONS_TO_EXCEPTIONS', false @@ -78,6 +79,7 @@ protected function shouldIgnoreDeprecationErrors(): bool return ! class_exists(LogManager::class) || self::$app === null || ! self::$app->hasBeenBootstrapped() + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the test runner sets this for the test process) || ! (bool) Env::get('LOG_DEPRECATIONS_WHILE_TESTING', true); } } diff --git a/src/testbench/src/Bootstrap/LoadConfiguration.php b/src/testbench/src/Bootstrap/LoadConfiguration.php index d464af46a2..47c916e516 100644 --- a/src/testbench/src/Bootstrap/LoadConfiguration.php +++ b/src/testbench/src/Bootstrap/LoadConfiguration.php @@ -29,6 +29,7 @@ public function bootstrap(Application $app): void $config->set('database.connections.testing', [ 'driver' => 'sqlite', 'database' => ':memory:', + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (this default connection is configuration, set while configuration loads) 'foreign_key_constraints' => (bool) Env::get('DB_FOREIGN_KEYS', false), ]); } @@ -50,7 +51,9 @@ protected function loadConfigurationFiles(Application $app, RepositoryContract $ ? $this->getBaseConfiguration() : []; - foreach ((new Collection($base))->diffKeys($files) as $name => $config) { + $baseOnly = (new Collection($base))->diffKeys($files)->all(); + + foreach ($baseOnly as $name => $config) { $repository->set($name, $config); } @@ -58,8 +61,12 @@ protected function loadConfigurationFiles(Application $app, RepositoryContract $ $base = $this->loadConfigurationFile($repository, $name, $path, $base); } + // An unchanged base-only entry is already set; setting it again discards the nested + // files loaded below it, as the foundation loader explains. foreach ($base as $name => $config) { - $repository->set($name, $config); + if (! array_key_exists($name, $baseOnly) || $baseOnly[$name] !== $config) { + $repository->set($name, $config); + } } } diff --git a/src/testbench/src/Bootstrapper.php b/src/testbench/src/Bootstrapper.php index 3859b17d04..5e003c0521 100644 --- a/src/testbench/src/Bootstrapper.php +++ b/src/testbench/src/Bootstrapper.php @@ -4,9 +4,9 @@ namespace Hypervel\Testbench; -use Composer\InstalledVersions; use FilesystemIterator; use Hypervel\Filesystem\Filesystem; +use Hypervel\Support\Composer; use Hypervel\Testbench\Contracts\Config as ConfigContract; use Hypervel\Testbench\Foundation\Config; use Hypervel\Testbench\Foundation\Env; @@ -32,10 +32,17 @@ class Bootstrapper */ protected static ?string $runtimePath = null; + /** + * The skeleton directory the runtime copy was made from. + */ + protected static ?string $runtimeSourcePath = null; + /** * Bootstrap the testbench environment. + * + * @param null|string $configurationPath the directory to load testbench.yaml from, instead of resolveConfigurationPath() */ - public static function bootstrap(): void + public static function bootstrap(?string $configurationPath = null): void { $workingPath = defined('TESTBENCH_WORKING_PATH') ? TESTBENCH_WORKING_PATH : package_path(); @@ -43,7 +50,9 @@ public static function bootstrap(): void define('TESTBENCH_WORKING_PATH', $workingPath); } - static::loadConfigFromYaml(static::resolveConfigurationPath($workingPath)); + static::exportAbsoluteComposerVendorDirectory($workingPath); + + static::loadConfigFromYaml($configurationPath ?? static::resolveConfigurationPath($workingPath)); if (defined('BASE_PATH')) { return; @@ -107,6 +116,17 @@ public static function ownsRuntimePath(string $path): bool return $runtimePath !== false && realpath($path) === $runtimePath; } + /** + * Get the skeleton directory the runtime copy created by this process was made from. + * + * Static analysis tracks a copied file through its skeleton source, because the copy is + * deleted when the process exits. + */ + public static function runtimeSourcePath(): ?string + { + return static::$runtimeSourcePath; + } + /** * Determine if the given path is a disposable runtime copy. * @@ -139,6 +159,7 @@ public static function isRuntimeCopy(string $path): bool */ public static function explicitApplicationBasePath(): ?string { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the base path is needed before the application exists) $basePath = $_ENV['APP_BASE_PATH'] ?? $_SERVER['APP_BASE_PATH'] ?? Env::get('APP_BASE_PATH'); return is_string($basePath) && $basePath !== '' ? $basePath : null; @@ -187,7 +208,7 @@ public static function resolveConfigurationPath(string $workingPath): string return $workingPath; } - return InstalledVersions::getRootPackage()['name'] === 'hypervel/components' + return Composer::rootPackage()['name'] === 'hypervel/components' ? testbench_path() : $workingPath; } @@ -206,6 +227,29 @@ protected static function hasConfigurationFile(string $workingPath, string $file return false; } + /** + * Make a relative COMPOSER_VENDOR_DIR absolute against the package root. + * + * Applications run from a runtime copy or skeleton, including child processes that + * inherit this environment such as the skeleton's artisan, resolve a relative value + * against their own base path rather than the package root. + */ + protected static function exportAbsoluteComposerVendorDirectory(string $workingPath): void + { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Composer sets this in the process environment) + $vendorPath = Env::get('COMPOSER_VENDOR_DIR'); + + if (! is_string($vendorPath) || $vendorPath === '' || str_starts_with($vendorPath, '/')) { + return; + } + + $vendorPath = Composer::vendorPath($workingPath); + + putenv("COMPOSER_VENDOR_DIR={$vendorPath}"); + $_ENV['COMPOSER_VENDOR_DIR'] = $vendorPath; + $_SERVER['COMPOSER_VENDOR_DIR'] = $vendorPath; + } + /** * Create a disposable runtime copy of the skeleton directory. * @@ -242,7 +286,7 @@ protected static function createRuntimeCopy(string $sourcePath, string $workingP throw new RuntimeException("Unable to create runtime path [{$runtimePath}]."); } - if (! static::copySkeleton($filesystem, $sourcePath, $runtimePath, join_paths($workingPath, 'vendor'))) { + if (! static::copySkeleton($filesystem, $sourcePath, $runtimePath, Composer::vendorPath($workingPath))) { throw new RuntimeException("Unable to create the Testbench runtime copy at [{$runtimePath}]."); } @@ -275,6 +319,7 @@ protected static function createRuntimeCopy(string $sourcePath, string $workingP } static::$runtimePath = $runtimePath; + static::$runtimeSourcePath = $sourcePath; register_shutdown_function(static function () use ($pid): void { // A forked child inherits this callback but owns no runtime copy. @@ -412,6 +457,7 @@ protected static function copyPackageEnvironmentFile(Filesystem $filesystem, str */ protected static function testbenchEnvironmentFile(): string { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (selects the environment file before it loads) $environmentFile = Env::get('TESTBENCH_ENVIRONMENT_FILENAME', '.env'); return is_string($environmentFile) && $environmentFile !== '' @@ -452,6 +498,7 @@ protected static function deleteRuntimeCopy(): void static::deleteRuntimeDirectory(static::$runtimePath); static::$runtimePath = null; + static::$runtimeSourcePath = null; } /** @@ -689,7 +736,7 @@ protected static function buildChildrenMap(): array public static function flushState(): void { static::$configuration = null; - // The runtime path and filesystem are process-wide infrastructure: + // The runtime paths and filesystem are process-wide infrastructure: // runtimePath must survive until shutdown cleanup, and Filesystem is reusable. } } diff --git a/src/testbench/src/Concerns/CreatesApplication.php b/src/testbench/src/Concerns/CreatesApplication.php index faf4877de3..1e594666d8 100644 --- a/src/testbench/src/Concerns/CreatesApplication.php +++ b/src/testbench/src/Concerns/CreatesApplication.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Http\Kernel as HttpKernelContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Eloquent\Model; use Hypervel\Di\Bootstrap\GenerateProxies; use Hypervel\Foundation\Application; @@ -28,6 +29,7 @@ use Hypervel\RateLimiter\Limit; use Hypervel\Routing\Router; use Hypervel\Support\Collection; +use Hypervel\Support\Composer; use Hypervel\Support\Facades\Facade; use Hypervel\Support\Facades\RateLimiter; use Hypervel\Testbench\Attributes\DefineEnvironment; @@ -52,6 +54,7 @@ use function Hypervel\Testbench\after_resolving; use function Hypervel\Testbench\default_skeleton_path; +use function Hypervel\Testbench\package_path; use function Hypervel\Testbench\refresh_router_lookups; /** @@ -268,6 +271,7 @@ public function createApplication(): ApplicationContract $this->resolveApplicationBindings($app); $this->resolveApplicationExceptionHandler($app); $this->resolveApplicationEnvironmentVariables($app); + $this->resolveApplicationComposerVendorDirectory(); $app->make(ConfigureTerminalDimensions::class)->bootstrap($app); $this->resolveApplicationConfiguration($app); @@ -394,13 +398,13 @@ protected function resolveApplicationCore(ApplicationContract $app): void */ protected function resolveApplicationEnvironmentVariables(ApplicationContract $app): void { - if (property_exists($this, 'loadEnvironmentVariables') && $this->loadEnvironmentVariables !== true) { /* @phpstan-ignore function.alreadyNarrowedType */ - if ($this instanceof PHPUnitTestCase && method_exists($this, 'beforeApplicationDestroyed')) { /* @phpstan-ignore function.alreadyNarrowedType */ + if (property_exists($this, 'loadEnvironmentVariables') && $this->loadEnvironmentVariables !== true) { /* @phpstan-ignore function.alreadyNarrowedType (not every class using this trait declares the property) */ + if ($this instanceof PHPUnitTestCase && method_exists($this, 'beforeApplicationDestroyed')) { $this->beforeApplicationDestroyed($this->maskInheritedApplicationEnvironment()); } } - if (property_exists($this, 'loadEnvironmentVariables') && $this->loadEnvironmentVariables === true) { /* @phpstan-ignore function.alreadyNarrowedType */ + if (property_exists($this, 'loadEnvironmentVariables') && $this->loadEnvironmentVariables === true) { /* @phpstan-ignore function.alreadyNarrowedType (not every class using this trait declares the property) */ $app->make(LoadEnvironmentVariables::class)->bootstrap($app); } @@ -417,13 +421,38 @@ protected function resolveApplicationEnvironmentVariables(ApplicationContract $a }, ); - if ($this instanceof PHPUnitTestCase && method_exists($this, 'beforeApplicationDestroyed')) { /* @phpstan-ignore function.alreadyNarrowedType */ + if ($this instanceof PHPUnitTestCase && method_exists($this, 'beforeApplicationDestroyed')) { $this->beforeApplicationDestroyed(static function () use ($attributeCallbacks) { $attributeCallbacks->reverse()->handle(); }); } } + /** + * Make a relative COMPOSER_VENDOR_DIR from the application environment absolute. + * + * Child processes such as the skeleton's artisan resolve a relative value against + * their own base path rather than the package root. Bootstrapper already exported + * an inherited value; rewriting a loaded one through the repository keeps its + * putenv setting and restoration. + * + * @internal + */ + protected function resolveApplicationComposerVendorDirectory(): void + { + $repository = Env::getRepository(); + $vendorPath = $repository->get('COMPOSER_VENDOR_DIR'); + + if ($vendorPath === null || $vendorPath === '' || str_starts_with($vendorPath, '/')) { + return; + } + + $vendorPath = Composer::vendorPath(package_path()); + + $repository->clear('COMPOSER_VENDOR_DIR'); + $repository->set('COMPOSER_VENDOR_DIR', $vendorPath); + } + /** * Mask inherited framework APP_ENV for Testbench applications that opt out of loading env files. * @@ -553,8 +582,8 @@ protected function resolveApplicationConfiguration(ApplicationContract $app): vo TestingFeature::run( testCase: $this, attribute: function () use ($app) { - $this->parseTestMethodAttributes($app, ResolvesHypervel::class); /* @phpstan-ignore method.notFound */ - $this->parseTestMethodAttributes($app, UsesFrameworkConfiguration::class); /* @phpstan-ignore method.notFound */ + $this->parseTestMethodAttributes($app, ResolvesHypervel::class); /* @phpstan-ignore method.notFound (provided by HandlesAttributes, which TestingFeature::run() checks) */ + $this->parseTestMethodAttributes($app, UsesFrameworkConfiguration::class); /* @phpstan-ignore method.notFound (provided by HandlesAttributes, which TestingFeature::run() checks) */ }, ); @@ -591,7 +620,7 @@ protected function resolveApplicationConfiguration(ApplicationContract $app): vo TestingFeature::run( testCase: $this, - attribute: fn () => $this->parseTestMethodAttributes($app, WithConfig::class), /* @phpstan-ignore method.notFound */ + attribute: fn () => $this->parseTestMethodAttributes($app, WithConfig::class), /* @phpstan-ignore method.notFound (provided by HandlesAttributes, which TestingFeature::run() checks) */ ); } @@ -689,13 +718,13 @@ protected function resolveApplicationBootstrappers(ApplicationContract $app): vo TestingFeature::run( testCase: $this, default: fn () => $this->defineEnvironment($app), - attribute: fn () => $this->parseTestMethodAttributes($app, DefineEnvironment::class), /* @phpstan-ignore method.notFound */ + attribute: fn () => $this->parseTestMethodAttributes($app, DefineEnvironment::class), /* @phpstan-ignore method.notFound (provided by HandlesAttributes, which TestingFeature::run() checks) */ ); $this->resolveApplicationRateLimiting($app); if (static::usesTestingConcern(WithWorkbench::class)) { - $this->bootDiscoverRoutesForWorkbench($app); /* @phpstan-ignore method.notFound */ + $this->bootDiscoverRoutesForWorkbench($app); /* @phpstan-ignore method.notFound (provided by WithWorkbench, checked above) */ } // Keep this after proxy generation because resolving db loads classes an aspect @@ -794,7 +823,7 @@ protected function paraTestWorkerToken(): ?string } /** - * Terminate and flush an application, preserving the first cleanup failure. + * Terminate and flush an application, returning any cleanup failure. */ protected static function terminateAndFlushApplication(ApplicationContract $app): ?Throwable { @@ -811,7 +840,7 @@ protected static function terminateAndFlushApplication(ApplicationContract $app) try { $app->flush(); } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } return $failure; diff --git a/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php b/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php index 00fe9c37f3..727c850b92 100644 --- a/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php +++ b/src/testbench/src/Concerns/Database/InteractsWithSqliteDatabaseFile.php @@ -7,6 +7,7 @@ use Hypervel\Database\SQLiteDatabase; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Collection; +use Hypervel\Support\Env; use Hypervel\Support\Facades\DB; use Hypervel\Testbench\Concerns\InteractsWithPublishedFiles; use InvalidArgumentException; @@ -44,7 +45,8 @@ protected function purgeSqliteConnection(): void protected function baseSqliteDatabasePath(): string { $database = $this->app->make('config')->string('database.connections.sqlite.database'); - $token = env('TEST_TOKEN'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN'); if ($token === null) { return $database; @@ -68,7 +70,8 @@ protected function baseSqliteDatabasePath(): string protected function activeSqliteDatabasePath(): string { $database = $this->baseSqliteDatabasePath(); - $token = env('TEST_TOKEN'); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parallel test runner sets each process's token in its environment) + $token = Env::get('TEST_TOKEN'); if ($token === null || $database === '' || SQLiteDatabase::isInMemory($database)) { return $database; diff --git a/src/testbench/src/Concerns/HandlesDatabases.php b/src/testbench/src/Concerns/HandlesDatabases.php index 602c2cf367..9ba72e5bc3 100644 --- a/src/testbench/src/Concerns/HandlesDatabases.php +++ b/src/testbench/src/Concerns/HandlesDatabases.php @@ -41,7 +41,7 @@ protected function setUpDatabaseRequirements(Closure $callback): void }); if (static::usesTestingConcern(WithHypervelMigrations::class)) { - $this->prepareHypervelMigrations(); /* @phpstan-ignore method.notFound */ + $this->prepareHypervelMigrations(); /* @phpstan-ignore method.notFound (provided by WithHypervelMigrations, checked above) */ } TestingFeature::run( diff --git a/src/testbench/src/Concerns/HandlesRoutes.php b/src/testbench/src/Concerns/HandlesRoutes.php index 08a0141e29..d77a09a4aa 100644 --- a/src/testbench/src/Concerns/HandlesRoutes.php +++ b/src/testbench/src/Concerns/HandlesRoutes.php @@ -7,6 +7,7 @@ use Attribute; use Closure; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application as HypervelApplication; use Hypervel\Routing\Router; @@ -121,7 +122,7 @@ protected function defineCacheRoutes(Closure|string $route, bool $cached = true) if ( $this->app instanceof HypervelApplication - && property_exists($this, 'setUpHasRun') /* @phpstan-ignore function.alreadyNarrowedType */ + && property_exists($this, 'setUpHasRun') /* @phpstan-ignore function.alreadyNarrowedType (not every class using this trait declares the property) */ && $this->setUpHasRun === true ) { $attribute->beforeEach($this->app); @@ -134,6 +135,7 @@ protected function defineCacheRoutes(Closure|string $route, bool $cached = true) if ($this->app instanceof HypervelApplication) { $cachedRoutesPath = $this->app->getCachedRoutesPath(); } else { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (cache paths are read before configuration loads, as Application::normalizeCachePath() does) $configuredCachedRoutesPath = Env::get('APP_ROUTES_CACHE'); $cachedRoutesPath = $configuredCachedRoutesPath === null @@ -247,7 +249,7 @@ protected function registerTestbenchRouteCleanup(Filesystem $files, string $cach )); } } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } if ($failure !== null) { diff --git a/src/testbench/src/Concerns/InteractsWithMigrations.php b/src/testbench/src/Concerns/InteractsWithMigrations.php index 4288b7d767..88ac9ead28 100644 --- a/src/testbench/src/Concerns/InteractsWithMigrations.php +++ b/src/testbench/src/Concerns/InteractsWithMigrations.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Console\Kernel as ConsoleKernelContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Migrations\Migrator; use Hypervel\Foundation\Console\Kernel as FoundationConsoleKernel; use Hypervel\Foundation\Testing\DatabaseMigrations; @@ -75,7 +76,7 @@ protected function tearDownInteractsWithMigrations(): void try { $migrator->rollback(); } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } } @@ -245,14 +246,14 @@ protected function usesInMemoryDatabaseForMigrationState(): bool { if ( static::usesRefreshDatabaseTestingConcern() - && $this->usingInMemoryDatabases() /* @phpstan-ignore method.notFound */ + && $this->usingInMemoryDatabases() /* @phpstan-ignore method.notFound (provided by RefreshDatabase, checked first) */ ) { return true; } if (static::usesTestingConcern(DatabaseTruncation::class)) { // Every cached truncation PDO needs the same class-boundary state owner. - return $this->usingInMemoryDatabasesForTruncation(); /* @phpstan-ignore method.notFound */ + return $this->usingInMemoryDatabasesForTruncation(); /* @phpstan-ignore method.notFound (provided by DatabaseTruncation, checked above) */ } return $this->usesSqliteInMemoryDatabaseConnection(); diff --git a/src/testbench/src/Concerns/InteractsWithPest.php b/src/testbench/src/Concerns/InteractsWithPest.php index 63a5ca082b..629a1767cb 100644 --- a/src/testbench/src/Concerns/InteractsWithPest.php +++ b/src/testbench/src/Concerns/InteractsWithPest.php @@ -26,7 +26,7 @@ public function isRunningTestCaseUsingPest(): bool */ protected static function isRunningViaPestPrinter(object|string $object): bool { - // @phpstan-ignore class.notFound + // @phpstan-ignore class.notFound (Pest is optional and only named here) return isset(class_implements($object, false)[HasPrintableTestCaseName::class]); } } diff --git a/src/testbench/src/Concerns/InteractsWithTestCase.php b/src/testbench/src/Concerns/InteractsWithTestCase.php index a7e57cf630..10326c54b3 100644 --- a/src/testbench/src/Concerns/InteractsWithTestCase.php +++ b/src/testbench/src/Concerns/InteractsWithTestCase.php @@ -5,6 +5,7 @@ namespace Hypervel\Testbench\Concerns; use Attribute; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Testing\LazilyRefreshDatabase; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Support\Collection; @@ -154,7 +155,7 @@ protected function tearDownTheTestEnvironmentUsingTestCase(): void try { $callback->afterEach($app); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } } catch (Throwable $throwable) { @@ -177,7 +178,6 @@ protected function tearDownTheTestEnvironmentUsingTestCase(): void public static function setUpBeforeClassUsingTestCase(): void { if (static::usesTestingConcern(WithFixtures::class)) { - /* @phpstan-ignore-next-line */ static::setupWithFixturesForTestingEnvironment(); } @@ -205,7 +205,7 @@ public static function tearDownAfterClassUsingTestCase(): void try { $callback->afterAll(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } } catch (Throwable $throwable) { diff --git a/src/testbench/src/Console/Commander.php b/src/testbench/src/Console/Commander.php index 174f3e00af..3340255920 100644 --- a/src/testbench/src/Console/Commander.php +++ b/src/testbench/src/Console/Commander.php @@ -9,9 +9,11 @@ use Hypervel\Contracts\Console\Kernel as ConsoleKernel; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application as HypervelApplication; use Hypervel\Foundation\Bootstrap\HandleExceptions; +use Hypervel\Support\Composer; use Hypervel\Support\ServiceProvider; use Hypervel\Testbench\Bootstrapper; use Hypervel\Testbench\Foundation\Application as Testbench; @@ -148,7 +150,7 @@ public function hypervel(): ApplicationContract { if (! $this->app instanceof HypervelApplication) { $appBasePath = $this->getApplicationBasePath(); - $vendorPath = package_path('vendor'); + $vendorPath = Composer::vendorPath(package_path()); TerminatingConsole::beforeWhen( ! is_symlink(join_paths($appBasePath, 'vendor')), @@ -219,7 +221,7 @@ private function terminateAndFlushApplication(ApplicationContract $app): ?Throwa try { $app->flush(); } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } return $failure; @@ -243,13 +245,13 @@ private function cleanUpCommand(?int $signal = null): ?Throwable try { Workbench::flush(); } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } try { $this->unregisterSignals(); } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } return $failure; @@ -325,14 +327,14 @@ protected function handleException(OutputInterface $output, Throwable $error): i // resolution, reporting, or rendering carries it as that failure's // previous. The return suppresses it once the status is produced. try { - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ throw $error; } finally { $handler = $this->app->make(ExceptionHandler::class); $handler->report($error); $handler->renderForConsole($output, $error); - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ return 1; } } diff --git a/src/testbench/src/Console/Task.php b/src/testbench/src/Console/Task.php index 5c682d5be1..06e4545800 100644 --- a/src/testbench/src/Console/Task.php +++ b/src/testbench/src/Console/Task.php @@ -68,7 +68,7 @@ public function dispatch(bool $pretending = false): void /** @var bool $action */ $action = $pretending === true ? true : call_user_func($this->action); - /* @phpstan-ignore argument.type */ + /* @phpstan-ignore argument.type (value()'s PHPDoc models one callback argument and no null; it calls this two-argument callback and returns null unchanged) */ value($this->response, $action, $pretending); } diff --git a/src/testbench/src/Database/MigrateProcessor.php b/src/testbench/src/Database/MigrateProcessor.php index d85f8401fe..721dc091df 100644 --- a/src/testbench/src/Database/MigrateProcessor.php +++ b/src/testbench/src/Database/MigrateProcessor.php @@ -5,6 +5,7 @@ namespace Hypervel\Testbench\Database; use Hypervel\Console\Command; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Migrations\Migrator; use Hypervel\Testbench\Contracts\TestCase; use RuntimeException; @@ -100,7 +101,7 @@ public function rollback(): static throw new RuntimeException("Migration batch [{$batch}] remains after rollback."); } } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } } }); diff --git a/src/testbench/src/Factories/UserFactory.php b/src/testbench/src/Factories/UserFactory.php index fc722e5b90..c647bc1594 100644 --- a/src/testbench/src/Factories/UserFactory.php +++ b/src/testbench/src/Factories/UserFactory.php @@ -7,6 +7,7 @@ use Hypervel\Database\Eloquent\Factories\Factory; use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Auth\User; +use Hypervel\Support\Env; use Hypervel\Support\Facades\Hash; use Hypervel\Support\Str; @@ -57,7 +58,8 @@ public function unverified(): static */ public function modelName(): string { - return $this->model ?? config('auth.providers.users.model') ?? env('AUTH_MODEL', User::class); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (falls back to the variable the auth configuration reads when that key is missing) + return $this->model ?? config('auth.providers.users.model') ?? Env::get('AUTH_MODEL', User::class); } /** diff --git a/src/testbench/src/Features/ParallelRunner.php b/src/testbench/src/Features/ParallelRunner.php index ed78bc67b1..5474fc4db6 100644 --- a/src/testbench/src/Features/ParallelRunner.php +++ b/src/testbench/src/Features/ParallelRunner.php @@ -23,6 +23,7 @@ class ParallelRunner extends BaseParallelRunner protected function createApplication(): ApplicationContract { if (! defined('TESTBENCH_WORKING_PATH')) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parent test process passes its working path through the environment) $workingPath = Env::get('TESTBENCH_WORKING_PATH'); if (is_string($workingPath) && $workingPath !== '') { diff --git a/src/testbench/src/Features/TestingFeature.php b/src/testbench/src/Features/TestingFeature.php index b9cd2c0f44..ed8cf0bf5f 100644 --- a/src/testbench/src/Features/TestingFeature.php +++ b/src/testbench/src/Features/TestingFeature.php @@ -32,7 +32,7 @@ public static function run( $defaultResolver = self::once($default); if ($testCase instanceof PHPUnitTestCase) { - /* @phpstan-ignore staticMethod.notFound */ + /* @phpstan-ignore staticMethod.notFound (provided by InteractsWithTestCase on Testbench test cases) */ if ($testCase::usesTestingConcern(HandlesAttributes::class)) { $result['attribute'] = value($attribute, $defaultResolver); } diff --git a/src/testbench/src/Foundation/Actions/RefreshPackageDiscovery.php b/src/testbench/src/Foundation/Actions/RefreshPackageDiscovery.php index 5814795798..3c17c3ee20 100644 --- a/src/testbench/src/Foundation/Actions/RefreshPackageDiscovery.php +++ b/src/testbench/src/Foundation/Actions/RefreshPackageDiscovery.php @@ -21,12 +21,13 @@ public function handle(ApplicationContract $app): void { $filesystem = new Filesystem; - $cachedPath = $app->bootstrapPath('cache/packages.php'); + $manifest = $app->make(PackageManifest::class); + $cachedPath = $manifest->manifestPath; - if ($filesystem->exists($cachedPath) && ! $filesystem->delete($cachedPath)) { + if ($cachedPath !== null && $filesystem->exists($cachedPath) && ! $filesystem->delete($cachedPath)) { throw new RuntimeException("Unable to delete package manifest [{$cachedPath}]."); } - $app->make(PackageManifest::class)->build(); + $manifest->build(); } } diff --git a/src/testbench/src/Foundation/Application.php b/src/testbench/src/Foundation/Application.php index d5943f8171..87c4319a19 100644 --- a/src/testbench/src/Foundation/Application.php +++ b/src/testbench/src/Foundation/Application.php @@ -22,7 +22,8 @@ * @phpstan-type TConfig array{ * extra?: TOptionalExtraConfig, * load_environment_variables?: bool, - * enables_package_discoveries?: bool + * enables_package_discoveries?: bool, + * discovers_root_package?: bool * } */ class Application @@ -69,6 +70,11 @@ class Application */ protected bool $loadEnvironmentVariables = false; + /** + * Discover the package's own Composer metadata outside the Testbench CLI. + */ + protected bool $discoversRootPackage = false; + /** * Create a new application resolver. * @@ -186,6 +192,10 @@ public function configure(array $options): static Arr::set($options, 'extra.dont-discover', $options['enables_package_discoveries'] ? [] : ['*']); } + if (isset($options['discovers_root_package']) && \is_bool($options['discovers_root_package'])) { + $this->discoversRootPackage = $options['discovers_root_package']; + } + $config = Arr::only($options['extra'] ?? [], array_keys($this->config)); /** @var array{ @@ -220,6 +230,14 @@ public function ignorePackageDiscoveriesFrom(): array return $this->config['dont-discover']; } + /** + * Determine if package discovery includes the package's own Composer metadata. + */ + public function discoversRootPackage(): bool + { + return $this->discoversRootPackage; + } + /** * Create the application instance. */ diff --git a/src/testbench/src/Foundation/Bootstrap/EnsuresDefaultConfiguration.php b/src/testbench/src/Foundation/Bootstrap/EnsuresDefaultConfiguration.php index ebdc6fcc67..4285f5c367 100644 --- a/src/testbench/src/Foundation/Bootstrap/EnsuresDefaultConfiguration.php +++ b/src/testbench/src/Foundation/Bootstrap/EnsuresDefaultConfiguration.php @@ -41,6 +41,7 @@ public function bootstrap(Application $app): void */ private function includesDefaultConfigurations(Application $app): bool { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (decides which configuration loads, so configuration cannot hold it) return Env::get('TESTBENCH_WITHOUT_DEFAULT_VARIABLES') !== true; } } diff --git a/src/testbench/src/Foundation/Bootstrap/LoadMigrationsFromArray.php b/src/testbench/src/Foundation/Bootstrap/LoadMigrationsFromArray.php index dd47275c81..782ca03155 100644 --- a/src/testbench/src/Foundation/Bootstrap/LoadMigrationsFromArray.php +++ b/src/testbench/src/Foundation/Bootstrap/LoadMigrationsFromArray.php @@ -110,6 +110,7 @@ private function bootstrapMigrations(Application $app): void private function includesDefaultMigrations(Application $app): bool { return workbench()['install'] === true + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (a Testbench switch set in the test process environment) && Env::get('TESTBENCH_WITHOUT_DEFAULT_MIGRATIONS') !== true && rescue(static fn (): bool => is_dir(default_migration_path()), false, false); } diff --git a/src/testbench/src/Foundation/Concerns/HandlesDatabaseConnections.php b/src/testbench/src/Foundation/Concerns/HandlesDatabaseConnections.php index 5937156908..8f5a26ce1c 100644 --- a/src/testbench/src/Foundation/Concerns/HandlesDatabaseConnections.php +++ b/src/testbench/src/Foundation/Concerns/HandlesDatabaseConnections.php @@ -44,6 +44,7 @@ final protected function usesDatabaseConnectionsEnvironmentVariables(Repository /** @var string $environmentSuffix */ $environmentSuffix = $options['env']; $environmentVariable = "{$keyword}_{$environmentSuffix}"; + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (builds database configuration from the environment, as a config file does) $port = Env::get($environmentVariable); if ($port === null || $port === '') { @@ -69,6 +70,7 @@ final protected function usesDatabaseConnectionsEnvironmentVariables(Repository /** @var mixed $configuration */ $configuration = (new Collection(Arr::wrap($options['env']))) + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (builds database configuration from the environment, as a config file does) ->transform(static fn (string $value): mixed => Env::get("{$keyword}_{$value}")) ->first( $options['rules'] ?? static fn ($value): bool => ! empty($value) && \is_string($value) diff --git a/src/testbench/src/Foundation/Config.php b/src/testbench/src/Foundation/Config.php index b043dc1536..038f57bfb8 100644 --- a/src/testbench/src/Foundation/Config.php +++ b/src/testbench/src/Foundation/Config.php @@ -179,6 +179,11 @@ class Config extends Fluent implements ConfigContract 'views' => false, ]; + /** + * The directory the configuration was loaded from. + */ + protected ?string $configurationPath = null; + /** * The cached configuration used during tests. * @@ -260,7 +265,10 @@ public static function loadFromYaml(string $workingPath, ?string $filename = 'te $config['workbench']['discovers'] ??= []; } - return new static($config); + $configuration = new static($config); + $configuration->configurationPath = $workingPath; + + return $configuration; } /** @@ -286,6 +294,14 @@ public function addProviders(array $providers): static return $this; } + /** + * Get the directory the configuration was loaded from, or null when it was not loaded from one. + */ + public function getConfigurationPath(): ?string + { + return $this->configurationPath; + } + /** * Get extra attributes. * diff --git a/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php b/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php index 724ee074b5..dc2519776b 100644 --- a/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php +++ b/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php @@ -122,7 +122,8 @@ protected function copyTestbenchDotEnvFile( protected function testbenchEnvironmentFile(): string { return match (true) { - property_exists($this, 'environmentFile') => $this->environmentFile, /* @phpstan-ignore function.alreadyNarrowedType */ + property_exists($this, 'environmentFile') => $this->environmentFile, + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (selects the environment file to copy) Env::has('TESTBENCH_ENVIRONMENT_FILENAME') => Env::get('TESTBENCH_ENVIRONMENT_FILENAME'), default => '.env', }; diff --git a/src/testbench/src/Foundation/Console/PurgeSkeletonCommand.php b/src/testbench/src/Foundation/Console/PurgeSkeletonCommand.php index 3d70aab5e6..5c5cd934cc 100644 --- a/src/testbench/src/Foundation/Console/PurgeSkeletonCommand.php +++ b/src/testbench/src/Foundation/Console/PurgeSkeletonCommand.php @@ -62,6 +62,7 @@ public function handle(Filesystem $filesystem, ConfigContract $config): int ['files' => $files, 'directories' => $directories] = $config->getPurgeAttributes(); + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (selects the environment file to delete) $environmentFile = Env::get('TESTBENCH_ENVIRONMENT_FILENAME', '.env'); $run(fn () => (new Actions\DeleteFiles( diff --git a/src/testbench/src/Foundation/Console/ServeCommand.php b/src/testbench/src/Foundation/Console/ServeCommand.php index 5bc4f5036c..f4dd6c41a9 100644 --- a/src/testbench/src/Foundation/Console/ServeCommand.php +++ b/src/testbench/src/Foundation/Console/ServeCommand.php @@ -8,6 +8,7 @@ use Hypervel\Console\OutputStyle; use Hypervel\Console\View\Components\Factory; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\Server\Commands\ServerStartCommand as Command; use Hypervel\Support\Env; @@ -38,6 +39,7 @@ protected function configure(): void // Like Laravel's development server, listen on the loopback address unless // SERVER_HOST or --host says otherwise. The framework default listens on // every interface, which would expose the Workbench login helpers. + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the development server reads its host from the process environment, as Laravel's does) $this->getDefinition()->getOption('host')->setDefault(Env::get('SERVER_HOST', '127.0.0.1')); } @@ -86,7 +88,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { (new RemoveAssetSymlinkFolders($files, $config))->handle(); } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } try { @@ -94,7 +96,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $events->dispatch(new ServeCommandEnded($input, $styledOutput, $components, $failure === null ? $exitCode : self::FAILURE)); } } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } if ($failure !== null) { diff --git a/src/testbench/src/Foundation/Console/TerminatingConsole.php b/src/testbench/src/Foundation/Console/TerminatingConsole.php index f8bb1b0080..fc0bd72b2e 100644 --- a/src/testbench/src/Foundation/Console/TerminatingConsole.php +++ b/src/testbench/src/Foundation/Console/TerminatingConsole.php @@ -4,6 +4,7 @@ namespace Hypervel\Testbench\Foundation\Console; +use Hypervel\Coroutine\Coroutine; use Throwable; /** @@ -55,7 +56,7 @@ public static function handle(?int $signal = null): void try { $callback($signal); } catch (Throwable $throwable) { - $failure ??= $throwable; + $failure = Coroutine::preferCancellation($failure, $throwable); } } diff --git a/src/testbench/src/Foundation/Console/stubs/user.stub b/src/testbench/src/Foundation/Console/stubs/user.stub index 638dd8584d..3d6d590c8c 100644 --- a/src/testbench/src/Foundation/Console/stubs/user.stub +++ b/src/testbench/src/Foundation/Console/stubs/user.stub @@ -4,38 +4,21 @@ declare(strict_types=1); namespace {{ WorkbenchAppNamespace }}\Models; +use Hypervel\Database\Eloquent\Attributes\Fillable; +use Hypervel\Database\Eloquent\Attributes\Hidden; use Hypervel\Database\Eloquent\Factories\HasFactory; use Hypervel\Foundation\Auth\User as Authenticatable; use Hypervel\Notifications\Notifiable; use {{ WorkbenchUserFactory }}; +#[Fillable(['name', 'email', 'password'])] +#[Hidden(['password', 'remember_token'])] class User extends Authenticatable { /** @use HasFactory */ use HasFactory; use Notifiable; - /** - * The attributes that are mass assignable. - * - * @var array - */ - protected array $fillable = [ - 'name', - 'email', - 'password', - ]; - - /** - * The attributes that should be hidden for serialization. - * - * @var array - */ - protected array $hidden = [ - 'password', - 'remember_token', - ]; - /** * Get the attributes that should be cast. * diff --git a/src/testbench/src/Foundation/Env.php b/src/testbench/src/Foundation/Env.php index 55c743de10..a0b87fd8c3 100644 --- a/src/testbench/src/Foundation/Env.php +++ b/src/testbench/src/Foundation/Env.php @@ -14,6 +14,7 @@ class Env extends \Hypervel\Support\Env */ public static function has(string $key): bool { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (has() is an Env reader built on get()) return static::get($key, new UndefinedValue) instanceof UndefinedValue === false; } @@ -38,6 +39,7 @@ public static function forget(string $key): bool */ public static function forward(string $key, mixed $default = new UndefinedValue): mixed { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (forward() is an Env reader built on get()) $value = static::get($key, $default); if ($value instanceof UndefinedValue) { diff --git a/src/testbench/src/Foundation/PackageManifest.php b/src/testbench/src/Foundation/PackageManifest.php index edf39bd2d8..693c0e7c09 100644 --- a/src/testbench/src/Foundation/PackageManifest.php +++ b/src/testbench/src/Foundation/PackageManifest.php @@ -9,6 +9,7 @@ use Hypervel\Foundation\PackageManifest as FoundationPackageManifest; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Testbench\Bootstrapper; use Override; use UnexpectedValueException; @@ -32,6 +33,13 @@ class PackageManifest extends FoundationPackageManifest */ protected array $requiredPackages = []; + /** + * The package's own manifest entry, keyed by its package name. + * + * @var null|array> + */ + protected ?array $rootManifest = null; + /** * Create a new package manifest instance. */ @@ -43,6 +51,11 @@ public function __construct( ) { parent::__construct($files, $basePath, $manifestPath); + // The base path is a disposable copy or a configured skeleton whose vendor link + // already points at the package's install. Resolving COMPOSER_VENDOR_DIR + // against the copy would miss it. + $this->vendorPath = $basePath . '/vendor'; + $this->setTestbench($testbench); } @@ -95,6 +108,39 @@ public function getManifestPath(): ?string return $this->manifestPath; } + /** + * Determine if the manifest includes the package's own Composer metadata. + */ + public function discoversRootPackage(): bool + { + return static::discoversRootPackageAt( + $this->basePath, + $this->testbench !== null + && method_exists($this->testbench, 'discoversRootPackage') + && $this->testbench->discoversRootPackage() === true, + ); + } + + /** + * Determine if a manifest for the given base path includes the package's own Composer metadata. + * + * Remote commands can start before an application exists, so the decision is also + * available without a manifest instance. + */ + public static function discoversRootPackageAt(string $basePath, bool $optedIn = false): bool + { + // A child sharing its parent's runtime follows the parent's decision, so its own + // CLI identity cannot change the manifest the parent and its subprocesses read. + if (Bootstrapper::isRuntimeCopy($basePath) && ! Bootstrapper::ownsRuntimePath($basePath)) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the parent process passes its decision to child processes through the environment) + return Env::get('TESTBENCH_DISCOVERS_ROOT_PACKAGE') === true; + } + + // PHPUnit workers are not Testbench CLI processes, but package:test workers + // still need the package root metadata. + return is_testbench_cli() || Env::has('TESTBENCH_PACKAGE_TESTER') || $optedIn; + } + /** * Get the current package manifest. */ @@ -141,17 +187,28 @@ protected function packagesToIgnore(): array */ protected function providersFromRoot(): array { + if ($this->rootManifest !== null) { + return $this->rootManifest; + } + $package = $this->providersFromTestbench(); if ($package === null) { - return []; + return $this->rootManifest = []; } $composerFile = package_path('composer.json'); $location = "root package in [{$composerFile}]"; + $extra = static::hypervelExtra($package, $location); + + // Composer only requires names for published packages. An unnamed root has no + // manifest entry to replace, so it needs a name only when its metadata is discovered. + if (! array_key_exists('name', $package) && ($extra === [] || ! $this->discoversRootPackage())) { + return $this->rootManifest = []; + } - return [ - static::packageName($package, $location, $this->vendorPath) => static::hypervelExtra($package, $location), + return $this->rootManifest = [ + static::packageName($package, $location, $this->vendorPath) => $extra, ]; } @@ -162,11 +219,7 @@ protected function providersFromRoot(): array */ protected function providersFromTestbench(): ?array { - // PHPUnit workers are not Testbench CLI processes, but package:test - // workers still need the package root metadata when they build a fresh - // clone manifest. - if ((is_testbench_cli() || Env::has('TESTBENCH_PACKAGE_TESTER')) - && is_file($composerFile = package_path('composer.json'))) { + if (is_file($composerFile = package_path('composer.json'))) { $package = $this->files->json($composerFile, JSON_THROW_ON_ERROR); if (! is_array($package)) { @@ -180,16 +233,19 @@ protected function providersFromTestbench(): ?array } /** - * Write the given manifest array to disk. + * Prepare a built or cached manifest before it is stored or used. */ #[Override] - protected function write(array $manifest): void + protected function prepareManifest(array $manifest): array { - parent::write( - (new Collection($manifest)) - ->merge($this->providersFromRoot()) - ->filter() - ->all() - ); + $root = $this->providersFromRoot(); + + // Replace any cached root entry, so the manifest follows the current discovery + // decision rather than whichever process last wrote the file. + $manifest = array_diff_key($manifest, $root); + + return $this->discoversRootPackage() + ? array_merge($manifest, array_filter($root)) + : $manifest; } } diff --git a/src/testbench/src/Foundation/Process/RemoteCommand.php b/src/testbench/src/Foundation/Process/RemoteCommand.php index 1ed4f2a552..ed64eeec79 100644 --- a/src/testbench/src/Foundation/Process/RemoteCommand.php +++ b/src/testbench/src/Foundation/Process/RemoteCommand.php @@ -6,8 +6,11 @@ use Closure; use Hypervel\Console\Application as ConsoleApplication; +use Hypervel\Container\Container; use Hypervel\Support\ProcessUtils; use Hypervel\Testbench\Bootstrapper; +use Hypervel\Testbench\Foundation\Env; +use Hypervel\Testbench\Foundation\PackageManifest; use Laravel\SerializableClosure\SerializableClosure; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\StringInput; @@ -66,6 +69,15 @@ public function handle(string $commander, Closure|array|string $command): Proces ? '(true)' : false; + // A child sharing this runtime follows this process's root discovery decision, + // so its own CLI identity cannot change the package manifest both processes read. + // Without an application yet, the decision comes from this process's context. + $env['TESTBENCH_DISCOVERS_ROOT_PACKAGE'] = $env['TESTBENCH_RUNTIME_COPY'] === '(true)' + ? Env::encode(Container::getInstance()->bound(PackageManifest::class) + ? Container::getInstance()->make(PackageManifest::class)->discoversRootPackage() + : PackageManifest::discoversRootPackageAt($env['TESTBENCH_BASE_PATH'])) + : false; + if (! array_key_exists('APP_ENV', $env)) { unset($definedEnvironmentVariables['APP_ENV']); } diff --git a/src/testbench/src/PHPUnit/TestCase.php b/src/testbench/src/PHPUnit/TestCase.php index 24c25f7ed3..2a9f89e5db 100644 --- a/src/testbench/src/PHPUnit/TestCase.php +++ b/src/testbench/src/PHPUnit/TestCase.php @@ -4,6 +4,7 @@ namespace Hypervel\Testbench\PHPUnit; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Bootstrap\HandleExceptions; use Hypervel\Testbench\Concerns\HandlesAssertions; use Hypervel\Testing\Concerns\InteractsWithMockery; @@ -35,7 +36,7 @@ protected function tearDown(): void try { $this->tearDownTheTestEnvironmentUsingMockery(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index 33607175b2..40ab385ee3 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -8,6 +8,7 @@ use Hypervel\Container\Container; use Hypervel\Coordinator\Constants; use Hypervel\Coordinator\CoordinatorManager; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application; use Hypervel\Foundation\Testing\DatabaseMigrations; @@ -146,7 +147,7 @@ protected function preservePackageManifestCache(): void protected function setUpDatabaseTraits(array $uses): void { if (isset($uses[Concerns\WithWorkbench::class])) { - $this->setUpWithWorkbench(); // @phpstan-ignore method.notFound + $this->setUpWithWorkbench(); // @phpstan-ignore method.notFound (provided by WithWorkbench, checked above) } // Reset before database attributes register paths against retained schema state. @@ -279,7 +280,7 @@ protected function tearDownTheTestbenchEnvironment(): void try { parent::tearDown(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { @@ -299,7 +300,7 @@ protected function tearDownTheTestbenchEnvironment(): void try { $teardown(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -347,7 +348,7 @@ public static function tearDownAfterClass(): void try { static::tearDownAfterClassUsingPHPUnit(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/testbench/src/Workbench/Actions/RemoveAssetSymlinkFolders.php b/src/testbench/src/Workbench/Actions/RemoveAssetSymlinkFolders.php index b46da725e7..24ed46bd2c 100644 --- a/src/testbench/src/Workbench/Actions/RemoveAssetSymlinkFolders.php +++ b/src/testbench/src/Workbench/Actions/RemoveAssetSymlinkFolders.php @@ -5,6 +5,7 @@ namespace Hypervel\Testbench\Workbench\Actions; use Closure; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Collection; use Hypervel\Testbench\Contracts\Config as ConfigContract; @@ -69,8 +70,8 @@ public function handle(): void try { value($payload[1], $payload[0]); } catch (Throwable $throwable) { - // Remove the remaining links before reporting the first failure. - $failure ??= $throwable; + // Remove the remaining links before reporting a failure. + $failure = Coroutine::preferCancellation($failure, $throwable); } }); diff --git a/src/testbench/src/Workbench/Workbench.php b/src/testbench/src/Workbench/Workbench.php index 878737ffff..79add907ea 100644 --- a/src/testbench/src/Workbench/Workbench.php +++ b/src/testbench/src/Workbench/Workbench.php @@ -311,6 +311,7 @@ public static function applicationExceptionHandler(): ?string public static function applicationUserModel(): ?string { if (\is_null(static::$cachedUserModel)) { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (Workbench detects the user model while configuration loads) $authModel = Env::get('AUTH_MODEL'); /** @var class-string|false $userModel */ diff --git a/src/testbench/src/functions.php b/src/testbench/src/functions.php index 1603cdbfbe..682822d010 100644 --- a/src/testbench/src/functions.php +++ b/src/testbench/src/functions.php @@ -15,6 +15,7 @@ use Hypervel\Routing\Router; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Support\Composer; use Hypervel\Support\ProcessUtils; use Hypervel\Testbench\Contracts\Config as ConfigContract; use Hypervel\Testbench\Contracts\TestCase as TestCaseContract; @@ -300,12 +301,13 @@ function package_path(array|string $path = ''): string $argumentCount = func_num_args(); $workingPath = once(static function (): string { + // @phpstan-ignore hypervel.noEnvCallsOutsideOfConfig (the test process sets the package working path in its environment) $configuredWorkingPath = Env::get('TESTBENCH_WORKING_PATH'); $resolvedPath = realpath(match (true) { defined('TESTBENCH_WORKING_PATH') => TESTBENCH_WORKING_PATH, is_string($configuredWorkingPath) && $configuredWorkingPath !== '' => $configuredWorkingPath, - default => InstalledVersions::getRootPackage()['install_path'], + default => Composer::rootPackage()['install_path'], }); return $resolvedPath !== false ? $resolvedPath : getcwd(); @@ -399,7 +401,7 @@ function hypervel_vendor_exists(ApplicationContract $app, ?string $workingPath = $filesystem = new \Hypervel\Filesystem\Filesystem; $appVendorPath = $app->basePath('vendor'); - $workingPath ??= package_path('vendor'); + $workingPath ??= Composer::vendorPath(package_path()); return $filesystem->isFile(join_paths($appVendorPath, 'autoload.php')) && $filesystem->hash(join_paths($appVendorPath, 'autoload.php')) === $filesystem->hash(join_paths($workingPath, 'autoload.php')); @@ -744,20 +746,16 @@ function php_binary(bool $escape = false): string */ function remote(Closure|array|string $command, array|string $env = [], ?bool $tty = null): ProcessDecorator { - $remote = new RemoteCommand(package_path(), $env, $tty); + if (is_string($env)) { + $env = ['APP_ENV' => $env]; + } - // Look for testbench binary in order of preference: - // 1. vendor/bin/testbench (installed as dependency) - // 2. src/testbench/bin/testbench (monorepo structure) - // 3. Fall back to 'testbench' in PATH - $vendorBinary = package_path('vendor', 'bin', 'testbench'); - $srcBinary = package_path('src', 'testbench', 'bin', 'testbench'); - - $commander = match (true) { - is_file($vendorBinary) => $vendorBinary, - is_file($srcBinary) => $srcBinary, - default => 'testbench', - }; + // Run this Testbench's own binary with the package's autoloader. That works for any + // Composer vendor or bin directory, and for path-repository installs where the + // binary's own location resolves to the source checkout. + $env['HYPERVEL_AUTOLOAD_PATH'] ??= Composer::vendorPath(package_path()) . '/autoload.php'; + + $remote = new RemoteCommand(package_path(), $env, $tty); - return $remote->handle($commander, $command); + return $remote->handle(testbench_path('bin/testbench'), $command); } diff --git a/src/testing/composer.json b/src/testing/composer.json index 071808a254..c5c99141d0 100644 --- a/src/testing/composer.json +++ b/src/testing/composer.json @@ -28,26 +28,53 @@ "ext-dom": "*", "ext-mbstring": "*", "composer-runtime-api": "^2.2", + "hypervel/auth": "^0.4", + "hypervel/broadcasting": "^0.4", + "hypervel/bus": "^0.4", + "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", + "hypervel/config": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", + "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/cookie": "^0.4", + "hypervel/coordinator": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/di": "^0.4", + "hypervel/encryption": "^0.4", + "hypervel/events": "^0.4", "hypervel/filesystem": "^0.4", "hypervel/foundation": "^0.4", "hypervel/guzzle-promises": "^3.1", "hypervel/http": "^0.4", + "hypervel/log": "^0.4", "hypervel/macroable": "^0.4", + "hypervel/mail": "^0.4", + "hypervel/notifications": "^0.4", + "hypervel/pagination": "^0.4", + "hypervel/pipeline": "^0.4", + "hypervel/process": "^0.4", "hypervel/prompts": "^0.4", + "hypervel/queue": "^0.4", + "hypervel/rate-limiter": "^0.4", + "hypervel/redis": "^0.4", + "hypervel/reflection": "^0.4", + "hypervel/routing": "^0.4", + "hypervel/server": "^0.4", + "hypervel/server-process": "^0.4", + "hypervel/session": "^0.4", "hypervel/support": "^0.4", + "hypervel/translation": "^0.4", + "hypervel/validation": "^0.4", "hypervel/view": "^0.4", + "hypervel/websocket-server": "^0.4", "mockery/mockery": "^1.6.15", "nesbot/carbon": "^3.13.1", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1", + "symfony/http-foundation": "^8.1.11", "symfony/process": "^8.1", "vlucas/phpdotenv": "^5.6.1" }, diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php index 8251c0efc3..9fdee21019 100644 --- a/src/testing/src/AssertableJsonString.php +++ b/src/testing/src/AssertableJsonString.php @@ -77,6 +77,8 @@ public function object(?string $key = null): mixed /** * Assert that the response JSON has the expected count of items at the given key. + * + * @return $this */ public function assertCount(int $count, ?string $key = null): static { @@ -101,6 +103,8 @@ public function assertCount(int $count, ?string $key = null): static /** * Assert that the response has the exact given JSON. + * + * @return $this */ public function assertExact(array $data): static { @@ -118,6 +122,8 @@ public function assertExact(array $data): static /** * Assert that the response has the similar JSON as given. + * + * @return $this */ public function assertSimilar(array $data): static { @@ -133,6 +139,8 @@ public function assertSimilar(array $data): static /** * Assert that the response contains the given JSON fragment. + * + * @return $this */ public function assertFragment(array $data): static { @@ -158,6 +166,8 @@ public function assertFragment(array $data): static /** * Assert that the response does not contain the given JSON fragment. + * + * @return $this */ public function assertMissing(array $data, bool $exact = false): static { @@ -187,6 +197,8 @@ public function assertMissing(array $data, bool $exact = false): static /** * Assert that the response does not contain the exact JSON fragment. + * + * @return $this */ public function assertMissingExact(array $data): static { @@ -213,6 +225,8 @@ public function assertMissingExact(array $data): static /** * Assert that the response does not contain the given path. + * + * @return $this */ public function assertMissingPath(string $path): static { @@ -262,6 +276,8 @@ protected function hasJsonPath(mixed $data, array $segments): bool /** * Assert that the expected value and type exists at the given path in the response. + * + * @return $this */ public function assertPath(string $path, mixed $expect): static { @@ -276,6 +292,8 @@ public function assertPath(string $path, mixed $expect): static /** * Assert that the given path in the response contains all of the expected values without looking at the order. + * + * @return $this */ public function assertPathCanonicalizing(string $path, array $expect): static { @@ -328,6 +346,8 @@ public function assertStructure(?array $structure = null, ?array $responseData = /** * Assert that the response is a superset of the given JSON. + * + * @return $this */ public function assertSubset(array $data, bool $strict = false): static { diff --git a/src/testing/src/Concerns/AssertsStatusCodes.php b/src/testing/src/Concerns/AssertsStatusCodes.php index a5ab36101c..9b73c44a3c 100644 --- a/src/testing/src/Concerns/AssertsStatusCodes.php +++ b/src/testing/src/Concerns/AssertsStatusCodes.php @@ -10,6 +10,8 @@ trait AssertsStatusCodes { /** * Assert that the response has a 200 "OK" status code. + * + * @return $this */ public function assertOk(): static { @@ -18,6 +20,8 @@ public function assertOk(): static /** * Assert that the response has a 201 "Created" status code. + * + * @return $this */ public function assertCreated(): static { @@ -26,6 +30,8 @@ public function assertCreated(): static /** * Assert that the response has a 202 "Accepted" status code. + * + * @return $this */ public function assertAccepted(): static { @@ -34,6 +40,8 @@ public function assertAccepted(): static /** * Assert that the response has the given status code and no content. + * + * @return $this */ public function assertNoContent(int $status = 204): static { @@ -46,6 +54,8 @@ public function assertNoContent(int $status = 204): static /** * Assert that the response has a 301 "Moved Permanently" status code. + * + * @return $this */ public function assertMovedPermanently(): static { @@ -54,6 +64,8 @@ public function assertMovedPermanently(): static /** * Assert that the response has a 302 "Found" status code. + * + * @return $this */ public function assertFound(): static { @@ -62,6 +74,8 @@ public function assertFound(): static /** * Assert that the response has a 304 "Not Modified" status code. + * + * @return $this */ public function assertNotModified(): static { @@ -70,6 +84,8 @@ public function assertNotModified(): static /** * Assert that the response has a 307 "Temporary Redirect" status code. + * + * @return $this */ public function assertTemporaryRedirect(): static { @@ -78,6 +94,8 @@ public function assertTemporaryRedirect(): static /** * Assert that the response has a 308 "Permanent Redirect" status code. + * + * @return $this */ public function assertPermanentRedirect(): static { @@ -86,6 +104,8 @@ public function assertPermanentRedirect(): static /** * Assert that the response has a 400 "Bad Request" status code. + * + * @return $this */ public function assertBadRequest(): static { @@ -94,6 +114,8 @@ public function assertBadRequest(): static /** * Assert that the response has a 401 "Unauthorized" status code. + * + * @return $this */ public function assertUnauthorized(): static { @@ -102,6 +124,8 @@ public function assertUnauthorized(): static /** * Assert that the response has a 402 "Payment Required" status code. + * + * @return $this */ public function assertPaymentRequired(): static { @@ -110,6 +134,8 @@ public function assertPaymentRequired(): static /** * Assert that the response has a 403 "Forbidden" status code. + * + * @return $this */ public function assertForbidden(): static { @@ -118,6 +144,8 @@ public function assertForbidden(): static /** * Assert that the response has a 404 "Not Found" status code. + * + * @return $this */ public function assertNotFound(): static { @@ -126,6 +154,8 @@ public function assertNotFound(): static /** * Assert that the response has a 405 "Method Not Allowed" status code. + * + * @return $this */ public function assertMethodNotAllowed(): static { @@ -134,6 +164,8 @@ public function assertMethodNotAllowed(): static /** * Assert that the response has a 406 "Not Acceptable" status code. + * + * @return $this */ public function assertNotAcceptable(): static { @@ -142,6 +174,8 @@ public function assertNotAcceptable(): static /** * Assert that the response has a 408 "Request Timeout" status code. + * + * @return $this */ public function assertRequestTimeout(): static { @@ -150,6 +184,8 @@ public function assertRequestTimeout(): static /** * Assert that the response has a 409 "Conflict" status code. + * + * @return $this */ public function assertConflict(): static { @@ -158,6 +194,8 @@ public function assertConflict(): static /** * Assert that the response has a 410 "Gone" status code. + * + * @return $this */ public function assertGone(): static { @@ -166,6 +204,8 @@ public function assertGone(): static /** * Assert that the response has a 415 "Unsupported Media Type" status code. + * + * @return $this */ public function assertUnsupportedMediaType(): static { @@ -174,6 +214,8 @@ public function assertUnsupportedMediaType(): static /** * Assert that the response has a 422 "Unprocessable Content" status code. + * + * @return $this */ public function assertUnprocessable(): static { @@ -182,6 +224,8 @@ public function assertUnprocessable(): static /** * Assert that the response has a 424 "Failed Dependency" status code. + * + * @return $this */ public function assertFailedDependency(): static { @@ -190,6 +234,8 @@ public function assertFailedDependency(): static /** * Assert that the response has a 429 "Too Many Requests" status code. + * + * @return $this */ public function assertTooManyRequests(): static { @@ -198,6 +244,8 @@ public function assertTooManyRequests(): static /** * Assert that the response has a 500 "Internal Server Error" status code. + * + * @return $this */ public function assertInternalServerError(): static { @@ -206,6 +254,8 @@ public function assertInternalServerError(): static /** * Assert that the response has a 503 "Service Unavailable" status code. + * + * @return $this */ public function assertServiceUnavailable(): static { diff --git a/src/testing/src/Concerns/InteractsWithMockery.php b/src/testing/src/Concerns/InteractsWithMockery.php index ea310c1f55..531de2ebc4 100644 --- a/src/testing/src/Concerns/InteractsWithMockery.php +++ b/src/testing/src/Concerns/InteractsWithMockery.php @@ -18,14 +18,10 @@ protected function tearDownTheTestEnvironmentUsingMockery(): void return; } - $container = Mockery::getContainer(); + /** @var int<0, max> $expectationCount */ + $expectationCount = Mockery::getContainer()->mockery_getExpectationCount(); - if ($container !== null) { /* @phpstan-ignore notIdentical.alwaysTrue */ - /** @var int<0, max> $expectationCount */ - $expectationCount = $container->mockery_getExpectationCount(); - - $this->addToAssertionCount($expectationCount); - } + $this->addToAssertionCount($expectationCount); Mockery::close(); } diff --git a/src/testing/src/Concerns/RunsInParallel.php b/src/testing/src/Concerns/RunsInParallel.php index 3f76179c89..c813fb2c55 100644 --- a/src/testing/src/Concerns/RunsInParallel.php +++ b/src/testing/src/Concerns/RunsInParallel.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Contracts\Console\Kernel; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Application; use Hypervel\Support\Collection; use Hypervel\Support\Facades\ParallelTesting; @@ -110,7 +111,7 @@ public function execute(): int try { $this->forProcess($token, fn () => ParallelTesting::callTearDownProcessCallbacks()); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -154,13 +155,13 @@ protected function forProcess(string $token, callable $callback): void try { $application->terminate(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } try { $application->flush(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/testing/src/Concerns/TestViews.php b/src/testing/src/Concerns/TestViews.php index 6506458f39..12cc3fb8c0 100644 --- a/src/testing/src/Concerns/TestViews.php +++ b/src/testing/src/Concerns/TestViews.php @@ -63,7 +63,7 @@ protected function switchToCompiledViewPath(string $path): void $compiler = $this->app->make('blade.compiler'); (function () use ($path) { - $this->cachePath = $path; /* @phpstan-ignore property.notFound */ + $this->cachePath = $path; /* @phpstan-ignore property.notFound (the closure is bound to the Blade compiler) */ })->bindTo($compiler, $compiler)(); } } diff --git a/src/testing/src/Console/TestCommandBase.php b/src/testing/src/Console/TestCommandBase.php index e8af8380c2..aa5147e7d4 100644 --- a/src/testing/src/Console/TestCommandBase.php +++ b/src/testing/src/Console/TestCommandBase.php @@ -10,6 +10,7 @@ use Dotenv\Parser\Parser; use Dotenv\Store\StoreBuilder; use Hypervel\Console\Command; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Collection; use Hypervel\Support\Env; use Hypervel\Support\Str; @@ -148,19 +149,19 @@ public function handle(): int try { $this->cleanupTemporaryConfigurationFile(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } try { $this->coverage?->cleanup(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } try { $this->cleanupProfileDirectory(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/testing/src/Fluent/AssertableJson.php b/src/testing/src/Fluent/AssertableJson.php index b536206708..e385be6aeb 100644 --- a/src/testing/src/Fluent/AssertableJson.php +++ b/src/testing/src/Fluent/AssertableJson.php @@ -64,6 +64,8 @@ protected function prop(?string $key = null): mixed /** * Instantiate a new "scope" at the path of the given key. + * + * @return $this */ protected function scope(string|int $key, Closure $callback): static { @@ -81,6 +83,8 @@ protected function scope(string|int $key, Closure $callback): static /** * Instantiate a new "scope" on the first child element. + * + * @return $this */ public function first(Closure $callback): static { @@ -104,6 +108,8 @@ public function first(Closure $callback): static /** * Instantiate a new "scope" on each child element. + * + * @return $this */ public function each(Closure $callback): static { diff --git a/src/testing/src/Fluent/Concerns/Debugging.php b/src/testing/src/Fluent/Concerns/Debugging.php index 6079b143dc..c967bb2271 100644 --- a/src/testing/src/Fluent/Concerns/Debugging.php +++ b/src/testing/src/Fluent/Concerns/Debugging.php @@ -12,6 +12,8 @@ trait Debugging /** * Dump the given props. + * + * @return $this */ public function dump(?string $prop = null): static { diff --git a/src/testing/src/Fluent/Concerns/Has.php b/src/testing/src/Fluent/Concerns/Has.php index 678902c19a..b6042002f0 100644 --- a/src/testing/src/Fluent/Concerns/Has.php +++ b/src/testing/src/Fluent/Concerns/Has.php @@ -12,6 +12,8 @@ trait Has { /** * Assert that the prop is of the expected size. + * + * @return $this */ public function count(string|int $key, ?int $length = null): static { @@ -40,6 +42,8 @@ public function count(string|int $key, ?int $length = null): static /** * Assert that the prop size is between a given minimum and maximum. + * + * @return $this */ public function countBetween(int|string $min, int|string $max): static { @@ -68,6 +72,8 @@ public function countBetween(int|string $min, int|string $max): static /** * Ensure that the given prop exists. + * + * @return $this */ public function has(string|int $key, int|Closure|null $length = null, ?Closure $callback = null): static { @@ -110,6 +116,8 @@ public function has(string|int $key, int|Closure|null $length = null, ?Closure $ /** * Assert that all of the given props exist. + * + * @return $this */ public function hasAll(array|string $key): static { @@ -128,6 +136,8 @@ public function hasAll(array|string $key): static /** * Assert that at least one of the given props exists. + * + * @return $this */ public function hasAny(array|string $key): static { @@ -147,6 +157,8 @@ public function hasAny(array|string $key): static /** * Assert that none of the given props exist. + * + * @return $this */ public function missingAll(array|string $key): static { @@ -161,6 +173,8 @@ public function missingAll(array|string $key): static /** * Assert that the given prop does not exist. + * + * @return $this */ public function missing(string $key): static { @@ -189,16 +203,22 @@ abstract protected function prop(?string $key = null): mixed; /** * Instantiate a new "scope" at the path of the given key. + * + * @return $this */ abstract protected function scope(string|int $key, Closure $callback): static; /** * Disable the interaction check. + * + * @return $this */ abstract public function etc(): static; /** * Instantiate a new "scope" on the first element. + * + * @return $this */ abstract public function first(Closure $callback): static; } diff --git a/src/testing/src/Fluent/Concerns/Interaction.php b/src/testing/src/Fluent/Concerns/Interaction.php index 14bd7bf17f..3bde7b6c6d 100644 --- a/src/testing/src/Fluent/Concerns/Interaction.php +++ b/src/testing/src/Fluent/Concerns/Interaction.php @@ -42,6 +42,8 @@ public function interacted(): void /** * Disable the interaction check. + * + * @return $this */ public function etc(): static { diff --git a/src/testing/src/Fluent/Concerns/Matching.php b/src/testing/src/Fluent/Concerns/Matching.php index 338a20cf32..e0572c3722 100644 --- a/src/testing/src/Fluent/Concerns/Matching.php +++ b/src/testing/src/Fluent/Concerns/Matching.php @@ -15,6 +15,8 @@ trait Matching { /** * Assert that the property matches the expected value. + * + * @return $this */ public function where(string $key, mixed $expected): static { @@ -49,6 +51,8 @@ public function where(string $key, mixed $expected): static /** * Assert that the property does not match the expected value. + * + * @return $this */ public function whereNot(string $key, mixed $expected): static { @@ -88,6 +92,8 @@ public function whereNot(string $key, mixed $expected): static /** * Assert that the property is null. + * + * @return $this */ public function whereNull(string $key): static { @@ -108,6 +114,8 @@ public function whereNull(string $key): static /** * Assert that the property is not null. + * + * @return $this */ public function whereNotNull(string $key): static { @@ -128,6 +136,8 @@ public function whereNotNull(string $key): static /** * Assert that all properties match their expected values. + * + * @return $this */ public function whereAll(array $bindings): static { @@ -140,6 +150,8 @@ public function whereAll(array $bindings): static /** * Assert that the property is of the expected type. + * + * @return $this */ public function whereType(string $key, array|string $expected): static { @@ -162,6 +174,8 @@ public function whereType(string $key, array|string $expected): static /** * Assert that all properties are of their expected types. + * + * @return $this */ public function whereAllType(array $bindings): static { @@ -174,6 +188,8 @@ public function whereAllType(array $bindings): static /** * Assert that the property contains the expected values. + * + * @return $this */ public function whereContains(string $key, mixed $expected): static { @@ -236,6 +252,8 @@ abstract protected function dotPath(string $key = ''): string; /** * Ensure that the given prop exists. + * + * @return $this */ abstract public function has(string|int $key, int|Closure|null $value = null, ?Closure $scope = null): static; diff --git a/src/testing/src/PHPUnit/AfterEachTestCleanup.php b/src/testing/src/PHPUnit/AfterEachTestCleanup.php index 5a0d2bfa30..21ad3c35d9 100644 --- a/src/testing/src/PHPUnit/AfterEachTestCleanup.php +++ b/src/testing/src/PHPUnit/AfterEachTestCleanup.php @@ -5,6 +5,7 @@ namespace Hypervel\Testing\PHPUnit; use Closure; +use Hypervel\Coroutine\Coroutine; use Throwable; /** @@ -48,8 +49,7 @@ public static function runCallbacks(): void try { $callback(); } catch (Throwable $throwable) { - // Keep running cleanup, then surface the first failure as the root cause. - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 470af95542..6427c796d1 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -4,6 +4,7 @@ namespace Hypervel\Testing\PHPUnit; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Mockery; use PHPUnit\Event\Test\Finished; @@ -81,19 +82,19 @@ public function flushStateAfterTest(): void try { Mockery::close(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } try { DatabaseConnectionResolver::flushCachedConnections(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } try { $this->flushFrameworkState(); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { @@ -115,8 +116,8 @@ protected function flushFrameworkState(): void \Carbon\Carbon::resetMacros(); \Carbon\Carbon::resetToStringFormat(); - \Carbon\Carbon::serializeUsing(null); - \Carbon\Carbon::useStrictMode(); + \Carbon\FactoryImmutable::getDefaultInstance()->serializeUsing(null); + \Carbon\FactoryImmutable::getDefaultInstance()->useStrictMode(); \Carbon\Carbon::setTestNow(); if (class_exists(\Laravel\SerializableClosure\SerializableClosure::class)) { diff --git a/src/testing/src/PHPUnit/TestStateRegistrars.php b/src/testing/src/PHPUnit/TestStateRegistrars.php index 75a63150f6..5607b62e5d 100644 --- a/src/testing/src/PHPUnit/TestStateRegistrars.php +++ b/src/testing/src/PHPUnit/TestStateRegistrars.php @@ -4,10 +4,9 @@ namespace Hypervel\Testing\PHPUnit; -use Composer\InstalledVersions; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\PackageManifest; -use Hypervel\Support\Env; +use Hypervel\Support\Composer; use RuntimeException; class TestStateRegistrars @@ -34,12 +33,8 @@ public function __construct( public static function forRootInstall(): static { $basePath = static::installedRootPath(); - $vendorPath = Env::get('COMPOSER_VENDOR_DIR'); - return new static( - $basePath, - is_string($vendorPath) && $vendorPath !== '' ? $vendorPath : $basePath . '/vendor' - ); + return new static($basePath, Composer::vendorPath($basePath)); } /** @@ -59,7 +54,7 @@ public function register(): void */ protected static function installedRootPath(): string { - $rootPackage = InstalledVersions::getRootPackage(); + $rootPackage = Composer::rootPackage(); return static::resolveInstalledRootPath($rootPackage['install_path'] ?? null); } diff --git a/src/testing/src/ParallelTesting.php b/src/testing/src/ParallelTesting.php index 4b36bb54b0..e4398021c7 100644 --- a/src/testing/src/ParallelTesting.php +++ b/src/testing/src/ParallelTesting.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Contracts\Container\Container; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Str; use Throwable; @@ -220,7 +221,7 @@ public function callTearDownTestCaseCallbacks(mixed $testCase): void } /** - * Call every teardown callback and preserve the first failure. + * Call every teardown callback, then throw any failure. * * @param list $callbacks * @param array $parameters @@ -233,7 +234,7 @@ private function callTearDownCallbacks(array $callbacks, array $parameters): voi try { $this->container->call($callback, $parameters); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } diff --git a/src/testing/src/PendingCommand.php b/src/testing/src/PendingCommand.php index 0b6c271d02..e13dd46d3d 100644 --- a/src/testing/src/PendingCommand.php +++ b/src/testing/src/PendingCommand.php @@ -67,6 +67,8 @@ public function __construct( /** * Specify an expected question that will be asked when the command runs. + * + * @return $this */ public function expectsQuestion(string $question, mixed $answer): static { @@ -77,6 +79,8 @@ public function expectsQuestion(string $question, mixed $answer): static /** * Specify an expected confirmation question that will be asked when the command runs. + * + * @return $this */ public function expectsConfirmation(string $question, string $answer = 'no'): static { @@ -85,6 +89,8 @@ public function expectsConfirmation(string $question, string $answer = 'no'): st /** * Specify an expected choice question with expected answers that will be asked/shown when the command runs. + * + * @return $this */ public function expectsChoice(string $question, array|string $answer, array $answers, bool $strict = false): static { @@ -98,6 +104,8 @@ public function expectsChoice(string $question, array|string $answer, array $ans /** * Specify an expected search question with an expected search string, followed by an expected choice question with expected answers. + * + * @return $this */ public function expectsSearch(string $question, array|string $answer, string $search, array $answers): static { @@ -108,6 +116,8 @@ public function expectsSearch(string $question, array|string $answer, string $se /** * Specify output that should be printed when the command runs. + * + * @return $this */ public function expectsOutput(?string $output = null): static { @@ -124,6 +134,8 @@ public function expectsOutput(?string $output = null): static /** * Specify output that should never be printed when the command runs. + * + * @return $this */ public function doesntExpectOutput(?string $output = null): static { @@ -140,6 +152,8 @@ public function doesntExpectOutput(?string $output = null): static /** * Specify that the given string should be contained in the command output. + * + * @return $this */ public function expectsOutputToContain(string $string): static { @@ -150,6 +164,8 @@ public function expectsOutputToContain(string $string): static /** * Specify that the given string shouldn't be contained in the command output. + * + * @return $this */ public function doesntExpectOutputToContain(string $string): static { @@ -160,6 +176,8 @@ public function doesntExpectOutputToContain(string $string): static /** * Specify a table that should be printed when the command runs. + * + * @return $this */ public function expectsTable(array $headers, array|Arrayable $rows, string $tableStyle = 'default', array $columnStyles = []): static { @@ -187,6 +205,8 @@ public function expectsTable(array $headers, array|Arrayable $rows, string $tabl /** * Specify that the given Prompts info message should be contained in the command output. + * + * @return $this */ public function expectsPromptsInfo(string $message): static { @@ -199,6 +219,8 @@ public function expectsPromptsInfo(string $message): static /** * Specify that the given Prompts warning message should be contained in the command output. + * + * @return $this */ public function expectsPromptsWarning(string $message): static { @@ -211,6 +233,8 @@ public function expectsPromptsWarning(string $message): static /** * Specify that the given Prompts error message should be contained in the command output. + * + * @return $this */ public function expectsPromptsError(string $message): static { @@ -223,6 +247,8 @@ public function expectsPromptsError(string $message): static /** * Specify that the given Prompts alert message should be contained in the command output. + * + * @return $this */ public function expectsPromptsAlert(string $message): static { @@ -235,6 +261,8 @@ public function expectsPromptsAlert(string $message): static /** * Specify that the given Prompts intro message should be contained in the command output. + * + * @return $this */ public function expectsPromptsIntro(string $message): static { @@ -247,6 +275,8 @@ public function expectsPromptsIntro(string $message): static /** * Specify that the given Prompts outro message should be contained in the command output. + * + * @return $this */ public function expectsPromptsOutro(string $message): static { @@ -264,6 +294,7 @@ public function expectsPromptsOutro(string $message): static * @param null|array>|Collection> $rows * * @phpstan-param ($rows is null ? list>|Collection> : list>|Collection>) $headers + * @return $this */ public function expectsPromptsTable(array|Collection $headers, array|Collection|null $rows): static { @@ -289,6 +320,8 @@ protected function expectOutputToContainPrompt(BasePrompt $prompt): void /** * Assert that the command has the given exit code. + * + * @return $this */ public function assertExitCode(int $exitCode): static { @@ -299,6 +332,8 @@ public function assertExitCode(int $exitCode): static /** * Assert that the command does not have the given exit code. + * + * @return $this */ public function assertNotExitCode(int $exitCode): static { @@ -309,6 +344,8 @@ public function assertNotExitCode(int $exitCode): static /** * Assert that the command has the success exit code. + * + * @return $this */ public function assertSuccessful(): static { @@ -317,6 +354,8 @@ public function assertSuccessful(): static /** * Assert that the command has the success exit code. + * + * @return $this */ public function assertOk(): static { @@ -325,6 +364,8 @@ public function assertOk(): static /** * Assert that the command does not have the success exit code. + * + * @return $this */ public function assertFailed(): static { diff --git a/src/testing/src/TestResponse.php b/src/testing/src/TestResponse.php index cc037b60cd..eb5dfb7630 100644 --- a/src/testing/src/TestResponse.php +++ b/src/testing/src/TestResponse.php @@ -105,6 +105,8 @@ public static function fromBaseResponse($response, ?Request $request = null): st /** * Assert that the response has a successful status code. + * + * @return $this */ public function assertSuccessful(): static { @@ -118,6 +120,8 @@ public function assertSuccessful(): static /** * Assert that the Precognition request was successful. + * + * @return $this */ public function assertSuccessfulPrecognition(): static { @@ -139,6 +143,8 @@ public function assertSuccessfulPrecognition(): static /** * Assert that the response is a client error. + * + * @return $this */ public function assertClientError(): static { @@ -152,6 +158,8 @@ public function assertClientError(): static /** * Assert that the response is a server error. + * + * @return $this */ public function assertServerError(): static { @@ -165,6 +173,8 @@ public function assertServerError(): static /** * Assert that the response has the given status code. + * + * @return $this */ public function assertStatus(int $status): static { @@ -185,6 +195,8 @@ protected function statusMessageWithDetails(int|string $expected, int|string $ac /** * Assert whether the response is redirecting to a given URI. + * + * @return $this */ public function assertRedirect(?string $uri = null): static { @@ -202,6 +214,8 @@ public function assertRedirect(?string $uri = null): static /** * Assert whether the response is redirecting to a URI that contains the given URI. + * + * @return $this */ public function assertRedirectContains(string $uri): static { @@ -220,6 +234,8 @@ public function assertRedirectContains(string $uri): static /** * Assert whether the response is redirecting back to the previous location. + * + * @return $this */ public function assertRedirectBack(): static { @@ -235,6 +251,8 @@ public function assertRedirectBack(): static /** * Assert whether the response is redirecting back to the previous location with the given errors in the session. + * + * @return $this */ public function assertRedirectBackWithErrors(array|string $keys = [], mixed $format = null, string $errorBag = 'default'): static { @@ -247,6 +265,8 @@ public function assertRedirectBackWithErrors(array|string $keys = [], mixed $for /** * Assert whether the response is redirecting back to the previous location with no errors in the session. + * + * @return $this */ public function assertRedirectBackWithoutErrors(): static { @@ -259,6 +279,8 @@ public function assertRedirectBackWithoutErrors(): static /** * Assert whether the response is redirecting to a given route. + * + * @return $this */ public function assertRedirectToRoute(BackedEnum|string $name, mixed $parameters = []): static { @@ -276,6 +298,8 @@ public function assertRedirectToRoute(BackedEnum|string $name, mixed $parameters /** * Assert whether the response is redirecting to a given signed route. + * + * @return $this */ public function assertRedirectToSignedRoute(BackedEnum|string|null $name = null, mixed $parameters = [], bool $absolute = true): static { @@ -312,6 +336,8 @@ public function assertRedirectToSignedRoute(BackedEnum|string|null $name = null, /** * Assert whether the response is redirecting to a given controller action. + * + * @return $this */ public function assertRedirectToAction(array|string $name, array $parameters = []): static { @@ -329,6 +355,8 @@ public function assertRedirectToAction(array|string $name, array $parameters = [ /** * Asserts that the response contains the given header and equals the optional value. + * + * @return $this */ public function assertHeader(string $headerName, mixed $value = null): static { @@ -352,6 +380,8 @@ public function assertHeader(string $headerName, mixed $value = null): static /** * Asserts that the response contains the given header and that its value contains the given string. + * + * @return $this */ public function assertHeaderContains(string $headerName, string $value): static { @@ -372,6 +402,8 @@ public function assertHeaderContains(string $headerName, string $value): static /** * Asserts that the response does not contain the given header. + * + * @return $this */ public function assertHeaderMissing(string $headerName): static { @@ -385,6 +417,8 @@ public function assertHeaderMissing(string $headerName): static /** * Assert that the current location header matches the given URI. + * + * @return $this */ public function assertLocation(string $uri): static { @@ -398,6 +432,8 @@ public function assertLocation(string $uri): static /** * Assert that the response offers a file download. + * + * @return $this */ public function assertDownload(?string $filename = null): static { @@ -443,6 +479,8 @@ public function assertDownload(?string $filename = null): static /** * Asserts that the response contains the given cookie and equals the optional value. + * + * @return $this */ public function assertPlainCookie(string $cookieName, mixed $value = null): static { @@ -453,6 +491,8 @@ public function assertPlainCookie(string $cookieName, mixed $value = null): stat /** * Asserts that the response contains the given cookie and equals the optional value. + * + * @return $this */ public function assertCookie(string $cookieName, mixed $value = null, bool $encrypted = true, bool $unserialize = false): static { @@ -478,6 +518,8 @@ public function assertCookie(string $cookieName, mixed $value = null, bool $encr /** * Asserts that the response contains the given cookie and is expired. + * + * @return $this */ public function assertCookieExpired(string $cookieName): static { @@ -498,6 +540,8 @@ public function assertCookieExpired(string $cookieName): static /** * Asserts that the response contains the given cookie and is not expired. + * + * @return $this */ public function assertCookieNotExpired(string $cookieName): static { @@ -518,6 +562,8 @@ public function assertCookieNotExpired(string $cookieName): static /** * Asserts that the response does not contain the given cookie. + * + * @return $this */ public function assertCookieMissing(string $cookieName): static { @@ -564,6 +610,8 @@ public function getCookie(string $cookieName, bool $decrypt = true, bool $unseri /** * Assert that the given string matches the response content. + * + * @return $this */ public function assertContent(string $value): static { @@ -574,6 +622,8 @@ public function assertContent(string $value): static /** * Assert that the response was streamed. + * + * @return $this */ public function assertStreamed(): static { @@ -587,6 +637,8 @@ public function assertStreamed(): static /** * Assert that the response was not streamed. + * + * @return $this */ public function assertNotStreamed(): static { @@ -600,6 +652,8 @@ public function assertNotStreamed(): static /** * Assert that the given string matches the streamed response content. + * + * @return $this */ public function assertStreamedContent(string $value): static { @@ -611,6 +665,8 @@ public function assertStreamedContent(string $value): static /** * Assert that the given array matches the streamed JSON response content. * + * @return $this + * * @throws JsonException */ public function assertStreamedJsonContent(array $value): static @@ -622,6 +678,7 @@ public function assertStreamedJsonContent(array $value): static * Assert that the given string or array of strings are contained within the response. * * @param list|string $value + * @return $this */ public function assertSee(array|string $value, bool $escape = true): static { @@ -640,6 +697,7 @@ public function assertSee(array|string $value, bool $escape = true): static * Assert that the given HTML string or array of HTML strings are contained within the response. * * @param list|string $value + * @return $this */ public function assertSeeHtml(array|string $value): static { @@ -650,6 +708,7 @@ public function assertSeeHtml(array|string $value): static * Assert that the given strings are contained in order within the response. * * @param list $values + * @return $this */ public function assertSeeInOrder(array $values, bool $escape = true): static { @@ -664,6 +723,7 @@ public function assertSeeInOrder(array $values, bool $escape = true): static * Assert that the given HTML strings are contained in order within the response. * * @param list $values + * @return $this */ public function assertSeeHtmlInOrder(array $values): static { @@ -674,6 +734,7 @@ public function assertSeeHtmlInOrder(array $values): static * Assert that the given string or array of strings are contained within the response text. * * @param list|string $value + * @return $this */ public function assertSeeText(array|string $value, bool $escape = true): static { @@ -690,6 +751,7 @@ public function assertSeeText(array|string $value, bool $escape = true): static * Assert that the given strings are contained in order within the response text. * * @param list $values + * @return $this */ public function assertSeeTextInOrder(array $values, bool $escape = true): static { @@ -704,6 +766,7 @@ public function assertSeeTextInOrder(array $values, bool $escape = true): static * Assert that the given string or array of strings are not contained within the response. * * @param list|string $value + * @return $this */ public function assertDontSee(array|string $value, bool $escape = true): static { @@ -722,6 +785,7 @@ public function assertDontSee(array|string $value, bool $escape = true): static * Assert that the given HTML string or array of HTML strings are not contained within the response. * * @param list|string $value + * @return $this */ public function assertDontSeeHtml(array|string $value): static { @@ -732,6 +796,7 @@ public function assertDontSeeHtml(array|string $value): static * Assert that the given string or array of strings are not contained within the response text. * * @param list|string $value + * @return $this */ public function assertDontSeeText(array|string $value, bool $escape = true): static { @@ -746,6 +811,8 @@ public function assertDontSeeText(array|string $value, bool $escape = true): sta /** * Assert that the response is a superset of the given JSON. + * + * @return $this */ public function assertJson(array|callable $value, bool $strict = false): static { @@ -768,6 +835,8 @@ public function assertJson(array|callable $value, bool $strict = false): static /** * Assert that the expected value and type exists at the given path in the response. + * + * @return $this */ public function assertJsonPath(string $path, mixed $expect): static { @@ -780,6 +849,7 @@ public function assertJsonPath(string $path, mixed $expect): static * Assert that the expected values and types exist at the given paths in the response. * * @param array $paths + * @return $this */ public function assertJsonPaths(array $paths): static { @@ -794,6 +864,7 @@ public function assertJsonPaths(array $paths): static * Assert that the given paths in the response contain all of the expected values without looking at the order. * * @param array> $paths + * @return $this */ public function assertJsonPathsCanonicalizing(array $paths): static { @@ -806,6 +877,8 @@ public function assertJsonPathsCanonicalizing(array $paths): static /** * Assert that the given path in the response contains all of the expected values without looking at the order. + * + * @return $this */ public function assertJsonPathCanonicalizing(string $path, array $expect): static { @@ -816,6 +889,8 @@ public function assertJsonPathCanonicalizing(string $path, array $expect): stati /** * Assert that the response has the exact given JSON. + * + * @return $this */ public function assertExactJson(array $data): static { @@ -826,6 +901,8 @@ public function assertExactJson(array $data): static /** * Assert that the response has the similar JSON as given. + * + * @return $this */ public function assertSimilarJson(array $data): static { @@ -836,6 +913,8 @@ public function assertSimilarJson(array $data): static /** * Assert that the response contains the given JSON fragments. + * + * @return $this */ public function assertJsonFragments(array $data): static { @@ -848,6 +927,8 @@ public function assertJsonFragments(array $data): static /** * Assert that the response contains the given JSON fragment. + * + * @return $this */ public function assertJsonFragment(array $data): static { @@ -858,6 +939,8 @@ public function assertJsonFragment(array $data): static /** * Assert that the response does not contain the given JSON fragment. + * + * @return $this */ public function assertJsonMissing(array $data, bool $exact = false): static { @@ -868,6 +951,8 @@ public function assertJsonMissing(array $data, bool $exact = false): static /** * Assert that the response does not contain the exact JSON fragment. + * + * @return $this */ public function assertJsonMissingExact(array $data): static { @@ -878,6 +963,8 @@ public function assertJsonMissingExact(array $data): static /** * Assert that the response does not contain the given path. + * + * @return $this */ public function assertJsonMissingPath(string $path): static { @@ -890,6 +977,7 @@ public function assertJsonMissingPath(string $path): static * Assert that the response does not contain the given paths. * * @param list $paths + * @return $this */ public function assertJsonMissingPaths(array $paths): static { @@ -902,6 +990,8 @@ public function assertJsonMissingPaths(array $paths): static /** * Assert that the response has a given JSON structure. + * + * @return $this */ public function assertJsonStructure(?array $structure = null, ?array $responseData = null): static { @@ -912,6 +1002,8 @@ public function assertJsonStructure(?array $structure = null, ?array $responseDa /** * Assert that the response has the exact JSON structure. + * + * @return $this */ public function assertExactJsonStructure(?array $structure = null, ?array $responseData = null): static { @@ -922,6 +1014,8 @@ public function assertExactJsonStructure(?array $structure = null, ?array $respo /** * Assert that the response JSON has the expected count of items at the given key. + * + * @return $this */ public function assertJsonCount(int $count, ?string $key = null): static { @@ -932,6 +1026,8 @@ public function assertJsonCount(int $count, ?string $key = null): static /** * Assert that the response has the given JSON validation errors. + * + * @return $this */ public function assertJsonValidationErrors(array|string|null $errors, string $responseKey = 'errors'): static { @@ -979,6 +1075,8 @@ public function assertJsonValidationErrors(array|string|null $errors, string $re /** * Assert that the response has the given JSON validation errors but does not have any other JSON validation errors. + * + * @return $this */ public function assertOnlyJsonValidationErrors(array|string|null $errors, string $responseKey = 'errors'): static { @@ -1002,6 +1100,8 @@ public function assertOnlyJsonValidationErrors(array|string|null $errors, string /** * Assert the response has any JSON validation errors for the given key. + * + * @return $this */ public function assertJsonValidationErrorFor(string $key, string $responseKey = 'errors'): static { @@ -1023,6 +1123,8 @@ public function assertJsonValidationErrorFor(string $key, string $responseKey = /** * Assert that the response has no JSON validation errors for the given keys. + * + * @return $this */ public function assertJsonMissingValidationErrors(array|string|null $keys = null, string $responseKey = 'errors'): static { @@ -1061,6 +1163,8 @@ public function assertJsonMissingValidationErrors(array|string|null $keys = null /** * Assert that the given key is a JSON array. + * + * @return $this */ public function assertJsonIsArray(?string $key = null): static { @@ -1074,6 +1178,8 @@ public function assertJsonIsArray(?string $key = null): static /** * Assert that the given key is a JSON object. + * + * @return $this */ public function assertJsonIsObject(?string $key = null): static { @@ -1130,6 +1236,8 @@ public function collect(?string $key = null): Collection /** * Assert that the response view equals the given value. + * + * @return $this */ public function assertViewIs(string $value): static { @@ -1142,6 +1250,8 @@ public function assertViewIs(string $value): static /** * Assert that the response view has a given piece of bound data. + * + * @return $this */ public function assertViewHas(array|string $key, mixed $value = null): static { @@ -1183,6 +1293,8 @@ public function assertViewHas(array|string $key, mixed $value = null): static /** * Assert that the response view has a given list of bound data. + * + * @return $this */ public function assertViewHasAll(array $bindings): static { @@ -1215,6 +1327,8 @@ public function viewData(?string $key = null): mixed /** * Assert that the response view is missing a piece of bound data. + * + * @return $this */ public function assertViewMissing(string $key): static { @@ -1227,6 +1341,8 @@ public function assertViewMissing(string $key): static /** * Ensure that the response has a view as its original content. + * + * @return $this */ protected function ensureResponseHasView(): static { @@ -1247,6 +1363,8 @@ protected function responseHasView(): bool /** * Assert that the given keys do not have validation errors. + * + * @return $this */ public function assertValid(array|string|null $keys = null, string $errorBag = 'default', string $responseKey = 'errors'): static { @@ -1285,6 +1403,8 @@ public function assertValid(array|string|null $keys = null, string $errorBag = ' /** * Assert that the response has the given validation errors. + * + * @return $this */ public function assertInvalid(array|string|null $errors = null, string $errorBag = 'default', string $responseKey = 'errors'): static { @@ -1334,6 +1454,8 @@ public function assertInvalid(array|string|null $errors = null, string $errorBag /** * Assert that the response has the given validation errors but does not have any other validation errors. + * + * @return $this */ public function assertOnlyInvalid(array|string|null $errors = null, string $errorBag = 'default', string $responseKey = 'errors'): static { @@ -1363,6 +1485,8 @@ public function assertOnlyInvalid(array|string|null $errors = null, string $erro /** * Assert that the session has a given value. + * + * @return $this */ public function assertSessionHas(array|string $key, mixed $value = null): static { @@ -1386,6 +1510,8 @@ public function assertSessionHas(array|string $key, mixed $value = null): static /** * Assert that the session has a given list of values. + * + * @return $this */ public function assertSessionHasAll(array $bindings): static { @@ -1413,6 +1539,8 @@ public function assertSessionHasAll(array $bindings): static /** * Assert that the session has a given value in the flashed input array. + * + * @return $this */ public function assertSessionHasInput(array|string $key, mixed $value = null): static { @@ -1468,6 +1596,8 @@ public function assertSessionMissingInput(array|string $key): static /** * Assert that the session has the given errors. + * + * @return $this */ public function assertSessionHasErrors(array|string $keys = [], mixed $format = null, string $errorBag = 'default'): static { @@ -1490,6 +1620,8 @@ public function assertSessionHasErrors(array|string $keys = [], mixed $format = /** * Assert that the session is missing the given errors. + * + * @return $this */ public function assertSessionDoesntHaveErrors(array|string $keys = [], ?string $format = null, string $errorBag = 'default'): static { @@ -1521,6 +1653,8 @@ public function assertSessionDoesntHaveErrors(array|string $keys = [], ?string $ /** * Assert that the session has no errors. * + * @return $this + * * @throws JsonException */ public function assertSessionHasNoErrors(): static @@ -1552,6 +1686,8 @@ public function assertSessionHasNoErrors(): static /** * Assert that the session has the given errors. + * + * @return $this */ public function assertSessionHasErrorsIn(string $errorBag, array|string $keys = [], mixed $format = null): static { @@ -1560,6 +1696,8 @@ public function assertSessionHasErrorsIn(string $errorBag, array|string $keys = /** * Assert that the session does not have a given key. + * + * @return $this */ public function assertSessionMissing(array|string $key, mixed $value = null): static { @@ -1637,6 +1775,8 @@ public function ddSession(array|string $keys = []): never /** * Dump the content from the response. + * + * @return $this */ public function dump(?string $key = null): static { @@ -1660,6 +1800,8 @@ public function dump(?string $key = null): static /** * Dump the headers from the response. + * + * @return $this */ public function dumpHeaders(): static { @@ -1670,6 +1812,8 @@ public function dumpHeaders(): static /** * Dump the session from the response. + * + * @return $this */ public function dumpSession(array|string $keys = []): static { @@ -1743,6 +1887,8 @@ protected function isStreamedResponse(): bool /** * Set the previous exceptions on the response. + * + * @return $this */ public function withExceptions(Collection $exceptions): static { diff --git a/src/testing/src/UnitTestCase.php b/src/testing/src/UnitTestCase.php index a12bd401a0..a863dcb988 100644 --- a/src/testing/src/UnitTestCase.php +++ b/src/testing/src/UnitTestCase.php @@ -4,6 +4,7 @@ namespace Hypervel\Testing; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Bootstrap\HandleExceptions; use Hypervel\Foundation\Testing\Concerns\InteractsWithEnvironment; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; @@ -38,8 +39,7 @@ protected function tearDown(): void try { $this->tearDownTheTestEnvironmentUsingMockery(); } catch (Throwable $throwable) { - // Preserve the first cleanup failure while still running Mockery teardown. - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/tinker/src/Console/TinkerCommand.php b/src/tinker/src/Console/TinkerCommand.php index 1c469c9c59..c1993a7f7e 100644 --- a/src/tinker/src/Console/TinkerCommand.php +++ b/src/tinker/src/Console/TinkerCommand.php @@ -5,7 +5,7 @@ namespace Hypervel\Tinker\Console; use Hypervel\Console\Command; -use Hypervel\Support\Env; +use Hypervel\Foundation\PackageManifest; use Hypervel\Tinker\ClassAliasAutoloader; use Psy\Configuration; use Psy\Exception\BreakException; @@ -68,9 +68,7 @@ public function handle(): int $shell->addCommands($this->getCommands()); $shell->setIncludes($this->argument('include')); - $path = Env::get('COMPOSER_VENDOR_DIR', $this->getHypervel()->basePath() . DIRECTORY_SEPARATOR . 'vendor'); - - $path .= '/composer/autoload_classmap.php'; + $path = $this->getHypervel()->make(PackageManifest::class)->vendorPath . '/composer/autoload_classmap.php'; $loader = ClassAliasAutoloader::register( $shell, diff --git a/src/translation/src/ArrayLoader.php b/src/translation/src/ArrayLoader.php index 1478597a02..fea336210e 100644 --- a/src/translation/src/ArrayLoader.php +++ b/src/translation/src/ArrayLoader.php @@ -46,6 +46,8 @@ public function addPath(string $path): void /** * Add messages to the loader. + * + * @return $this */ public function addMessages(string $locale, string $group, array $messages, ?string $namespace = null): static { diff --git a/src/translation/src/MessageSelector.php b/src/translation/src/MessageSelector.php index e65ec43f27..65e9d36fb6 100644 --- a/src/translation/src/MessageSelector.php +++ b/src/translation/src/MessageSelector.php @@ -400,7 +400,7 @@ public function getPluralIndex(string $locale, float|int $number): int case 'ar_SY': case 'ar_TN': case 'ar_YE': - return ((float) $number === 0.0) ? 0 : (((float) $number === 1.0) ? 1 : (((float) $number === 2.0) ? 2 : ((((int) $number % 100 >= 3) && ((int) $number % 100 <= 10)) ? 3 : ((((int) $number % 100 >= 11) && ((int) $number % 100 <= 99)) ? 4 : 5)))); // @phpstan-ignore smallerOrEqual.alwaysTrue + return ((float) $number === 0.0) ? 0 : (((float) $number === 1.0) ? 1 : (((float) $number === 2.0) ? 2 : ((((int) $number % 100 >= 3) && ((int) $number % 100 <= 10)) ? 3 : (((int) $number % 100 >= 11) ? 4 : 5)))); // Laravel also checks "<= 99", which a remainder of 100 always satisfies. default: return 0; } diff --git a/src/translation/src/PotentiallyTranslatedString.php b/src/translation/src/PotentiallyTranslatedString.php index f10ee87dc3..cb831e1dd5 100644 --- a/src/translation/src/PotentiallyTranslatedString.php +++ b/src/translation/src/PotentiallyTranslatedString.php @@ -29,6 +29,8 @@ public function __construct( /** * Translate the string. + * + * @return $this */ public function translate(array $replace = [], ?string $locale = null): static { @@ -39,6 +41,8 @@ public function translate(array $replace = [], ?string $locale = null): static /** * Translate the string based on a count. + * + * @return $this */ public function translateChoice(array|Countable|float|int $number, array $replace = [], ?string $locale = null): static { diff --git a/src/validation/composer.json b/src/validation/composer.json index 18a24651b6..e805d2b4c8 100644 --- a/src/validation/composer.json +++ b/src/validation/composer.json @@ -42,7 +42,7 @@ "hypervel/support": "^0.4", "hypervel/translation": "^0.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "suggest": { "hypervel/foundation": "Required to use ValidatesWhenResolvedTrait with precognitive requests." diff --git a/src/validation/src/ClosureValidationRule.php b/src/validation/src/ClosureValidationRule.php index 12f89bd920..052029b3bb 100644 --- a/src/validation/src/ClosureValidationRule.php +++ b/src/validation/src/ClosureValidationRule.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Translation\CreatesPotentiallyTranslatedStrings; +// @phpstan-ignore class.implementsDeprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) class ClosureValidationRule implements RuleContract, ValidatorAwareRule { use CreatesPotentiallyTranslatedStrings; @@ -65,6 +66,8 @@ public function message(): array /** * Set the current validator. + * + * @return $this */ public function setValidator(Validator $validator): static { diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index 6a100aa9f5..d203f7b2fe 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -346,7 +346,7 @@ protected function getDateTimeWithOptionalFormat(string $format, mixed $value): protected function getDateTime(DateTimeInterface|float|int|string $value): ?DateTimeInterface { try { - return @Date::parse($value) ?: null; // @phpstan-ignore ternary.alwaysTrue (Date::parse() PHPDoc claims non-null but can fail at runtime) + return @Date::parse($value) ?: null; } catch (Exception) { } @@ -1124,7 +1124,7 @@ protected function getExistCount(mixed $connection, string $table, string $colum $verifier = $this->getPresenceVerifier($connection); $extra = $this->getExtraConditions( - array_values(array_slice($parameters, 2)) // @phpstan-ignore arrayValues.list + array_values(array_slice($parameters, 2)) ); if ($this->currentRule instanceof Exists) { @@ -2641,7 +2641,7 @@ public function validateUuid(string $attribute, mixed $value, mixed $parameters) { $version = null; - if ($parameters !== null && count($parameters) === 1) { // @phpstan-ignore notIdentical.alwaysTrue + if ($parameters !== null && count($parameters) === 1) { $version = $parameters[0]; if ($version !== 'max') { diff --git a/src/validation/src/ConditionalRules.php b/src/validation/src/ConditionalRules.php index ffa110a949..28db792a31 100644 --- a/src/validation/src/ConditionalRules.php +++ b/src/validation/src/ConditionalRules.php @@ -27,7 +27,9 @@ class ConditionalRules */ public function __construct( bool|callable $condition, + // @phpstan-ignore parameter.deprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.), parameter.deprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) protected array|Closure|Rule|string|Stringable|ValidationRule $rules, + // @phpstan-ignore parameter.deprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.), parameter.deprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) protected array|Closure|Rule|string|Stringable|ValidationRule $defaultRules = [] ) { $this->condition = is_bool($condition) ? $condition : $condition(...); diff --git a/src/validation/src/Factory.php b/src/validation/src/Factory.php index c64f2859e9..849de82a4c 100644 --- a/src/validation/src/Factory.php +++ b/src/validation/src/Factory.php @@ -289,6 +289,8 @@ public function getContainer(): ?Container * Tests only. Swaps the container on the singleton validation Factory; * per-request use races across coroutines and breaks every concurrent * validator build. + * + * @return $this */ public function setContainer(Container $container): static { diff --git a/src/validation/src/InvokableValidationRule.php b/src/validation/src/InvokableValidationRule.php index 3dcd2b6641..efb27139d3 100644 --- a/src/validation/src/InvokableValidationRule.php +++ b/src/validation/src/InvokableValidationRule.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Translation\CreatesPotentiallyTranslatedStrings; +// @phpstan-ignore class.implementsDeprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) class InvokableValidationRule implements Rule, ValidatorAwareRule { use CreatesPotentiallyTranslatedStrings; @@ -51,6 +52,7 @@ protected function __construct( public static function make(ValidationRule $invokable): InvokableValidationRule { if ($invokable->implicit ?? false) { + // @phpstan-ignore class.implementsDeprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) return new class($invokable) extends InvokableValidationRule implements ImplicitRule {}; } @@ -99,6 +101,8 @@ public function message(): array /** * Set the data under validation. + * + * @return $this */ public function setData(array $data): static { @@ -109,6 +113,8 @@ public function setData(array $data): static /** * Set the current validator. + * + * @return $this */ public function setValidator(Validator $validator): static { diff --git a/src/validation/src/Rules/AnyOf.php b/src/validation/src/Rules/AnyOf.php index f4c47c8abb..79c3fac1e8 100644 --- a/src/validation/src/Rules/AnyOf.php +++ b/src/validation/src/Rules/AnyOf.php @@ -10,6 +10,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\Facades\Validator; +// @phpstan-ignore class.implementsDeprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) class AnyOf implements Rule, ValidatorAwareRule { /** @@ -42,8 +43,8 @@ public function passes(string $attribute, mixed $value): bool $validator = Validator::make( $keyed && is_array($value) ? $value : [$value], $keyed ? $rule : [$rule], - $this->validator->customMessages, // @phpstan-ignore property.notFound - $this->validator->customAttributes // @phpstan-ignore property.notFound + $this->validator->customMessages, // @phpstan-ignore property.notFound (public property of the framework Validator) + $this->validator->customAttributes // @phpstan-ignore property.notFound (public property of the framework Validator) ); if ($validator->passes()) { @@ -68,6 +69,8 @@ public function message(): array|string /** * Set the current validator. + * + * @return $this */ public function setValidator(ValidatorContract $validator): static { diff --git a/src/validation/src/Rules/Can.php b/src/validation/src/Rules/Can.php index 10c15d0d2e..836d18b25b 100644 --- a/src/validation/src/Rules/Can.php +++ b/src/validation/src/Rules/Can.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Support\Facades\Gate; +// @phpstan-ignore class.implementsDeprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) class Can implements Rule, ValidatorAwareRule { /** @@ -51,6 +52,8 @@ public function message(): array|string /** * Set the current validator. + * + * @return $this */ public function setValidator(Validator $validator): static { diff --git a/src/validation/src/Rules/DatabaseRule.php b/src/validation/src/Rules/DatabaseRule.php index 4a6d56336a..2cad43ffe4 100644 --- a/src/validation/src/Rules/DatabaseRule.php +++ b/src/validation/src/Rules/DatabaseRule.php @@ -70,6 +70,8 @@ public function resolveTableName(string $table): string /** * Set a "where" constraint on the query. + * + * @return $this */ public function where(Closure|string $column, mixed $value = null): static { @@ -94,6 +96,8 @@ public function where(Closure|string $column, mixed $value = null): static /** * Set a "where not" constraint on the query. + * + * @return $this */ public function whereNot(string $column, mixed $value): static { @@ -108,6 +112,8 @@ public function whereNot(string $column, mixed $value): static /** * Set a "where null" constraint on the query. + * + * @return $this */ public function whereNull(string $column): static { @@ -116,6 +122,8 @@ public function whereNull(string $column): static /** * Set a "where not null" constraint on the query. + * + * @return $this */ public function whereNotNull(string $column): static { @@ -124,6 +132,8 @@ public function whereNotNull(string $column): static /** * Set a "where in" constraint on the query. + * + * @return $this */ public function whereIn(string $column, array|Arrayable|BackedEnum $values): static { @@ -134,6 +144,8 @@ public function whereIn(string $column, array|Arrayable|BackedEnum $values): sta /** * Set a "where not in" constraint on the query. + * + * @return $this */ public function whereNotIn(string $column, array|Arrayable|BackedEnum $values): static { @@ -144,6 +156,8 @@ public function whereNotIn(string $column, array|Arrayable|BackedEnum $values): /** * Ignore soft deleted models during the existence checks. + * + * @return $this */ public function withoutTrashed(string $deletedAtColumn = 'deleted_at'): static { @@ -154,6 +168,8 @@ public function withoutTrashed(string $deletedAtColumn = 'deleted_at'): static /** * Only include soft deleted models during the existence check. + * + * @return $this */ public function onlyTrashed(string $deletedAtColumn = 'deleted_at'): static { @@ -164,6 +180,8 @@ public function onlyTrashed(string $deletedAtColumn = 'deleted_at'): static /** * Register a custom query callback. + * + * @return $this */ public function using(Closure $callback): static { diff --git a/src/validation/src/Rules/Dimensions.php b/src/validation/src/Rules/Dimensions.php index c9ce9a3a13..9ebe7033a2 100644 --- a/src/validation/src/Rules/Dimensions.php +++ b/src/validation/src/Rules/Dimensions.php @@ -23,6 +23,8 @@ public function __construct( /** * Set the "width" constraint. + * + * @return $this */ public function width(int|string $value): static { @@ -33,6 +35,8 @@ public function width(int|string $value): static /** * Set the "height" constraint. + * + * @return $this */ public function height(int|string $value): static { @@ -43,6 +47,8 @@ public function height(int|string $value): static /** * Set the "min width" constraint. + * + * @return $this */ public function minWidth(int|string $value): static { @@ -53,6 +59,8 @@ public function minWidth(int|string $value): static /** * Set the "min height" constraint. + * + * @return $this */ public function minHeight(int|string $value): static { @@ -63,6 +71,8 @@ public function minHeight(int|string $value): static /** * Set the "max width" constraint. + * + * @return $this */ public function maxWidth(int|string $value): static { @@ -73,6 +83,8 @@ public function maxWidth(int|string $value): static /** * Set the "max height" constraint. + * + * @return $this */ public function maxHeight(int|string $value): static { @@ -83,6 +95,8 @@ public function maxHeight(int|string $value): static /** * Set the "ratio" constraint. + * + * @return $this */ public function ratio(float|string $value): static { @@ -93,6 +107,8 @@ public function ratio(float|string $value): static /** * Set the minimum aspect ratio. + * + * @return $this */ public function minRatio(float|string $value): static { @@ -103,6 +119,8 @@ public function minRatio(float|string $value): static /** * Set the maximum aspect ratio. + * + * @return $this */ public function maxRatio(float|string $value): static { @@ -113,6 +131,8 @@ public function maxRatio(float|string $value): static /** * Set the aspect ratio range. + * + * @return $this */ public function ratioBetween(float|string $min, float|string $max): static { diff --git a/src/validation/src/Rules/Email.php b/src/validation/src/Rules/Email.php index 587eeccc5b..b2fd8bd8f2 100644 --- a/src/validation/src/Rules/Email.php +++ b/src/validation/src/Rules/Email.php @@ -15,6 +15,7 @@ use Hypervel\Validation\Rules\Concerns\ClonesCustomRules; use InvalidArgumentException; +// @phpstan-ignore class.implementsDeprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) class Email implements Rule, DataAwareRule, ValidatorAwareRule { use Conditionable; @@ -111,6 +112,8 @@ public static function default(): static /** * Ensure that the email is an RFC compliant email address. + * + * @return $this */ public function rfcCompliant(bool $strict = false): static { @@ -125,6 +128,8 @@ public function rfcCompliant(bool $strict = false): static /** * Ensure that the email is a strictly enforced RFC compliant email address. + * + * @return $this */ public function strict(): static { @@ -135,6 +140,8 @@ public function strict(): static * Check the email domain's MX records, with A or AAAA records as a fallback. * * Requires the PHP intl extension. + * + * @return $this */ public function validateMxRecord(): static { @@ -145,6 +152,8 @@ public function validateMxRecord(): static /** * Ensure that the email address is not attempting to spoof another email address using invalid unicode characters. + * + * @return $this */ public function preventSpoofing(): static { @@ -155,6 +164,8 @@ public function preventSpoofing(): static /** * Ensure the email address is valid using PHP's native email validation functions. + * + * @return $this */ public function withNativeValidation(bool $allowUnicode = false): static { @@ -169,6 +180,8 @@ public function withNativeValidation(bool $allowUnicode = false): static /** * Specify additional validation rules that should be merged with the default rules during validation. + * + * @return $this */ public function rules(array|string $rules): static { @@ -187,8 +200,8 @@ public function passes(string $attribute, mixed $value): bool $validator = Validator::make( $this->data, [$attribute => $this->buildValidationRules()], - $this->validator->customMessages, // @phpstan-ignore property.notFound - $this->validator->customAttributes // @phpstan-ignore property.notFound + $this->validator->customMessages, // @phpstan-ignore property.notFound (public property of the framework Validator) + $this->validator->customAttributes // @phpstan-ignore property.notFound (public property of the framework Validator) ); if ($validator->fails()) { @@ -250,6 +263,8 @@ public function message(): array /** * Set the current validator. + * + * @return $this */ public function setValidator(ValidatorContract $validator): static { @@ -260,6 +275,8 @@ public function setValidator(ValidatorContract $validator): static /** * Set the current data under validation. + * + * @return $this */ public function setData(array $data): static { diff --git a/src/validation/src/Rules/Enum.php b/src/validation/src/Rules/Enum.php index 7745d7d406..1621513134 100644 --- a/src/validation/src/Rules/Enum.php +++ b/src/validation/src/Rules/Enum.php @@ -17,6 +17,7 @@ use function Hypervel\Support\enum_try_from; use function Hypervel\Support\enum_value; +// @phpstan-ignore class.implementsDeprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) class Enum implements Rule, Stringable, ValidatorAwareRule { use Conditionable; @@ -68,6 +69,7 @@ public function passes(string $attribute, mixed $value): bool * Specify the cases that should be considered valid. * * @param Arrayable>|UnitEnum|UnitEnum[] $values + * @return $this */ public function only(mixed $values): static { @@ -80,6 +82,7 @@ public function only(mixed $values): static * Specify the cases that should be considered invalid. * * @param Arrayable>|UnitEnum|UnitEnum[] $values + * @return $this */ public function except(mixed $values): static { @@ -114,6 +117,8 @@ public function message(): array|string /** * Set the current validator. + * + * @return $this */ public function setValidator(Validator $validator): static { diff --git a/src/validation/src/Rules/File.php b/src/validation/src/Rules/File.php index f15493d128..c77b2921ad 100644 --- a/src/validation/src/Rules/File.php +++ b/src/validation/src/Rules/File.php @@ -17,6 +17,7 @@ use InvalidArgumentException; use Stringable; +// @phpstan-ignore class.implementsDeprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) class File implements Rule, DataAwareRule, ValidatorAwareRule { use Conditionable; @@ -144,6 +145,8 @@ public static function types(array|string $mimetypes): static /** * Limit the uploaded file to the given file extensions. + * + * @return $this */ public function extensions(array|string $extensions): static { @@ -154,6 +157,8 @@ public function extensions(array|string $extensions): static /** * Indicate that the uploaded file should be exactly a certain size in kilobytes. + * + * @return $this */ public function size(int|string $size): static { @@ -165,6 +170,8 @@ public function size(int|string $size): static /** * Indicate that the uploaded file should be between a minimum and maximum size in kilobytes. + * + * @return $this */ public function between(int|string $minSize, int|string $maxSize): static { @@ -176,6 +183,8 @@ public function between(int|string $minSize, int|string $maxSize): static /** * Indicate that the uploaded file should be no less than the given number of kilobytes. + * + * @return $this */ public function min(int|string $size): static { @@ -186,6 +195,8 @@ public function min(int|string $size): static /** * Indicate that the uploaded file should be no more than the given number of kilobytes. + * + * @return $this */ public function max(int|string $size): static { @@ -196,6 +207,8 @@ public function max(int|string $size): static /** * Indicate that the uploaded file should be in the given encoding. + * + * @return $this */ public function encoding(string $encoding): static { @@ -232,6 +245,8 @@ protected function toKilobytes(int|string $size): int|float /** * Specify additional validation rules that should be merged with the default rules during validation. + * + * @return $this */ public function rules(array|string|Stringable $rules): static { @@ -250,8 +265,8 @@ public function passes(string $attribute, mixed $value): bool $validator = Validator::make( $this->data, [$attribute => $this->buildValidationRules()], - $this->validator->customMessages, // @phpstan-ignore property.notFound - $this->validator->customAttributes // @phpstan-ignore property.notFound + $this->validator->customMessages, // @phpstan-ignore property.notFound (public property of the framework Validator) + $this->validator->customAttributes // @phpstan-ignore property.notFound (public property of the framework Validator) ); if ($validator->fails()) { @@ -338,6 +353,8 @@ public function message(): array /** * Set the current validator. + * + * @return $this */ public function setValidator(ValidatorContract $validator): static { @@ -348,6 +365,8 @@ public function setValidator(ValidatorContract $validator): static /** * Set the current data under validation. + * + * @return $this */ public function setData(array $data): static { diff --git a/src/validation/src/Rules/ImageFile.php b/src/validation/src/Rules/ImageFile.php index 7b3e4beda6..e89b5c861b 100644 --- a/src/validation/src/Rules/ImageFile.php +++ b/src/validation/src/Rules/ImageFile.php @@ -20,6 +20,8 @@ public function __construct(bool $allowSvg = false) /** * The dimension constraints for the uploaded file. + * + * @return $this */ public function dimensions(Dimensions $dimensions): static { diff --git a/src/validation/src/Rules/Numeric.php b/src/validation/src/Rules/Numeric.php index 697c3bbf1f..d377d89a1e 100644 --- a/src/validation/src/Rules/Numeric.php +++ b/src/validation/src/Rules/Numeric.php @@ -27,6 +27,8 @@ public function __construct(bool $strict = false) /** * The field under validation must have a size between the given min and max (inclusive). + * + * @return $this */ public function between(float|int $min, float|int $max): static { @@ -35,6 +37,8 @@ public function between(float|int $min, float|int $max): static /** * The field under validation must contain the specified number of decimal places. + * + * @return $this */ public function decimal(int $min, ?int $max = null): static { @@ -49,6 +53,8 @@ public function decimal(int $min, ?int $max = null): static /** * The field under validation must have a different value than field. + * + * @return $this */ public function different(string $field): static { @@ -57,6 +63,8 @@ public function different(string $field): static /** * The integer under validation must have an exact number of digits. + * + * @return $this */ public function digits(int $length): static { @@ -65,6 +73,8 @@ public function digits(int $length): static /** * The integer under validation must between the given min and max number of digits. + * + * @return $this */ public function digitsBetween(int $min, int $max): static { @@ -73,6 +83,8 @@ public function digitsBetween(int $min, int $max): static /** * The field under validation must be greater than the given field or value. + * + * @return $this */ public function greaterThan(string $field): static { @@ -81,6 +93,8 @@ public function greaterThan(string $field): static /** * The field under validation must be greater than or equal to the given field or value. + * + * @return $this */ public function greaterThanOrEqualTo(string $field): static { @@ -89,6 +103,8 @@ public function greaterThanOrEqualTo(string $field): static /** * The field under validation must be an integer. + * + * @return $this */ public function integer(bool $strict = false): static { @@ -97,6 +113,8 @@ public function integer(bool $strict = false): static /** * The field under validation must be less than the given field. + * + * @return $this */ public function lessThan(string $field): static { @@ -105,6 +123,8 @@ public function lessThan(string $field): static /** * The field under validation must be less than or equal to the given field. + * + * @return $this */ public function lessThanOrEqualTo(string $field): static { @@ -113,6 +133,8 @@ public function lessThanOrEqualTo(string $field): static /** * The field under validation must be less than or equal to a maximum value. + * + * @return $this */ public function max(float|int $value): static { @@ -121,6 +143,8 @@ public function max(float|int $value): static /** * The integer under validation must have a maximum number of digits. + * + * @return $this */ public function maxDigits(int $value): static { @@ -129,6 +153,8 @@ public function maxDigits(int $value): static /** * The field under validation must have a minimum value. + * + * @return $this */ public function min(float|int $value): static { @@ -137,6 +163,8 @@ public function min(float|int $value): static /** * The integer under validation must have a minimum number of digits. + * + * @return $this */ public function minDigits(int $value): static { @@ -145,6 +173,8 @@ public function minDigits(int $value): static /** * The field under validation must be a multiple of the given value. + * + * @return $this */ public function multipleOf(float|int $value): static { @@ -153,6 +183,8 @@ public function multipleOf(float|int $value): static /** * The given field must match the field under validation. + * + * @return $this */ public function same(string $field): static { @@ -161,6 +193,8 @@ public function same(string $field): static /** * The field under validation must match the given value. + * + * @return $this */ public function exactly(int $value): static { @@ -187,6 +221,8 @@ public function toArray(): array /** * Add custom rules to the validation rules array. + * + * @return $this */ protected function addRule(array|string $rules): static { diff --git a/src/validation/src/Rules/Password.php b/src/validation/src/Rules/Password.php index f798b3b101..b224ad3968 100644 --- a/src/validation/src/Rules/Password.php +++ b/src/validation/src/Rules/Password.php @@ -21,6 +21,7 @@ use IteratorAggregate; use Traversable; +// @phpstan-ignore class.implementsDeprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.), class.implementsDeprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) class Password implements DataAwareRule, ImplicitRule, IteratorAggregate, Rule, ValidatorAwareRule { use Conditionable; @@ -186,6 +187,8 @@ public static function sometimes(): static /** * Set the performing validator. + * + * @return $this */ public function setValidator(ValidatorContract $validator): static { @@ -196,6 +199,8 @@ public function setValidator(ValidatorContract $validator): static /** * Set the data under validation. + * + * @return $this */ public function setData(array $data): static { @@ -214,6 +219,8 @@ public static function min(int $size): static /** * Set the maximum size of the password. + * + * @return $this */ public function max(int $size): static { @@ -224,6 +231,8 @@ public function max(int $size): static /** * Ensures the password has not been compromised in data leaks. + * + * @return $this */ public function uncompromised(int $threshold = 0): static { @@ -236,6 +245,8 @@ public function uncompromised(int $threshold = 0): static /** * Makes the password require at least one uppercase and one lowercase letter. + * + * @return $this */ public function mixedCase(): static { @@ -246,6 +257,8 @@ public function mixedCase(): static /** * Makes the password require at least one letter. + * + * @return $this */ public function letters(): static { @@ -256,6 +269,8 @@ public function letters(): static /** * Makes the password require at least one number. + * + * @return $this */ public function numbers(): static { @@ -266,6 +281,8 @@ public function numbers(): static /** * Makes the password require at least one symbol. + * + * @return $this */ public function symbols(): static { @@ -276,8 +293,10 @@ public function symbols(): static /** * Specify additional validation rules that should be merged with the default rules during validation. + * + * @return $this */ - public function rules(array|Closure|Rule|string $rules): static + public function rules(array|Closure|Rule|string $rules): static // @phpstan-ignore parameter.deprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) { $this->customRules = Arr::wrap($rules); @@ -302,8 +321,8 @@ public function passes(string $attribute, mixed $value): bool $validator = Validator::make( $this->data, [$attribute => [...$this]], - $this->validator->customMessages, // @phpstan-ignore property.notFound - $this->validator->customAttributes // @phpstan-ignore property.notFound + $this->validator->customMessages, // @phpstan-ignore property.notFound (public property of the framework Validator) + $this->validator->customAttributes // @phpstan-ignore property.notFound (public property of the framework Validator) )->after(function ($validator) use ($attribute, $value) { if (! is_string($value)) { return; diff --git a/src/validation/src/Rules/StringRule.php b/src/validation/src/Rules/StringRule.php index d636d1d773..1df9576e46 100644 --- a/src/validation/src/Rules/StringRule.php +++ b/src/validation/src/Rules/StringRule.php @@ -19,6 +19,8 @@ class StringRule implements Stringable /** * The field under validation must be entirely alphabetic characters. + * + * @return $this */ public function alpha(bool $ascii = false): static { @@ -27,6 +29,8 @@ public function alpha(bool $ascii = false): static /** * The field under validation must be entirely alpha-numeric characters, dashes, and underscores. + * + * @return $this */ public function alphaDash(bool $ascii = false): static { @@ -35,6 +39,8 @@ public function alphaDash(bool $ascii = false): static /** * The field under validation must be entirely alpha-numeric characters. + * + * @return $this */ public function alphaNumeric(bool $ascii = false): static { @@ -43,6 +49,8 @@ public function alphaNumeric(bool $ascii = false): static /** * The field under validation must be entirely ASCII characters. + * + * @return $this */ public function ascii(): static { @@ -51,6 +59,8 @@ public function ascii(): static /** * The field under validation must have a length between the given min and max (inclusive). + * + * @return $this */ public function between(int $min, int $max): static { @@ -59,6 +69,8 @@ public function between(int $min, int $max): static /** * The field under validation must not end with any of the given values. + * + * @return $this */ public function doesntEndWith(string ...$values): static { @@ -67,6 +79,8 @@ public function doesntEndWith(string ...$values): static /** * The field under validation must not start with any of the given values. + * + * @return $this */ public function doesntStartWith(string ...$values): static { @@ -75,6 +89,8 @@ public function doesntStartWith(string ...$values): static /** * The field under validation must end with one of the given values. + * + * @return $this */ public function endsWith(string ...$values): static { @@ -83,6 +99,8 @@ public function endsWith(string ...$values): static /** * The field under validation must have an exact length. + * + * @return $this */ public function exactly(int $value): static { @@ -91,6 +109,8 @@ public function exactly(int $value): static /** * The field under validation must be entirely lowercase. + * + * @return $this */ public function lowercase(): static { @@ -99,6 +119,8 @@ public function lowercase(): static /** * The field under validation must not exceed the given length. + * + * @return $this */ public function max(int $value): static { @@ -107,6 +129,8 @@ public function max(int $value): static /** * The field under validation must have a minimum length. + * + * @return $this */ public function min(int $value): static { @@ -115,6 +139,8 @@ public function min(int $value): static /** * The field under validation must start with one of the given values. + * + * @return $this */ public function startsWith(string ...$values): static { @@ -123,6 +149,8 @@ public function startsWith(string ...$values): static /** * The field under validation must be entirely uppercase. + * + * @return $this */ public function uppercase(): static { @@ -162,6 +190,8 @@ protected function formatValues(array $values): string /** * Add custom rules to the validation rules array. + * + * @return $this */ protected function addRule(array|string $rules): static { diff --git a/src/validation/src/Rules/Unique.php b/src/validation/src/Rules/Unique.php index 3134a7846b..b68cdcbeaa 100644 --- a/src/validation/src/Rules/Unique.php +++ b/src/validation/src/Rules/Unique.php @@ -25,6 +25,8 @@ class Unique implements Stringable /** * Ignore the given ID during the unique check. + * + * @return $this */ public function ignore(mixed $id, ?string $idColumn = null): static { @@ -40,6 +42,8 @@ public function ignore(mixed $id, ?string $idColumn = null): static /** * Ignore the given model during the unique check. + * + * @return $this */ public function ignoreModel(Model $model, ?string $idColumn = null): static { diff --git a/src/validation/src/ValidationException.php b/src/validation/src/ValidationException.php index 5c6b85e410..605286031d 100644 --- a/src/validation/src/ValidationException.php +++ b/src/validation/src/ValidationException.php @@ -83,6 +83,8 @@ public function errors(): array /** * Set the HTTP status code to be used for the response. + * + * @return $this */ public function status(int $status): static { @@ -93,6 +95,8 @@ public function status(int $status): static /** * Set the error bag on the exception. + * + * @return $this */ public function errorBag(string $errorBag): static { @@ -103,6 +107,8 @@ public function errorBag(string $errorBag): static /** * Set the URL to redirect to on a validation error. + * + * @return $this */ public function redirectTo(string $url): static { diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index a23981f508..d3f64a6619 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -423,6 +423,8 @@ protected function replaceDotPlaceholderInParameters(array $parameters): array /** * Add an after validation callback. + * + * @return $this */ public function after(array|callable|string $callback): static { @@ -1059,7 +1061,7 @@ public function validateWithBag(string $errorBag): array * Get a validated input container for the validated input. * * @param null|array $keys - * @return ($keys is array ? array : ValidatedInput) + * @return ($keys is array ? array : ValidatedInput) * * @throws ValidationException */ @@ -1381,7 +1383,7 @@ protected function hasNotFailedPreviousRuleIfPresenceRule(object|string $rule, s /** * Validate an attribute using a custom rule object. */ - protected function validateUsingCustomRule(string $attribute, mixed $value, Rule $rule): void + protected function validateUsingCustomRule(string $attribute, mixed $value, Rule $rule): void // @phpstan-ignore parameter.deprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) { $attributeWithPlaceholders = $attribute; @@ -1410,14 +1412,14 @@ protected function validateUsingCustomRule(string $attribute, mixed $value, Rule $rule->setData($this->data); } - if (! $rule->passes($attribute, $value)) { + if (! $rule->passes($attribute, $value)) { // @phpstan-ignore method.deprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) $ruleClass = $rule instanceof InvokableValidationRule ? get_class($rule->invokable()) : get_class($rule); $this->failedRules[$originalAttribute][$ruleClass] = []; - $messages = $this->getFromLocalArray($attributeWithPlaceholders, $ruleClass) ?? $rule->message(); + $messages = $this->getFromLocalArray($attributeWithPlaceholders, $ruleClass) ?? $rule->message(); // @phpstan-ignore method.deprecatedInterface (Laravel's validation Rule/ImplicitRule contracts are deprecated but still supported.) $messages = $messages ? (array) $messages : [$ruleClass]; @@ -1640,6 +1642,8 @@ public function getData(): array /** * Set the data under validation. + * + * @return $this */ public function setData(array $data): static { @@ -1688,6 +1692,8 @@ public function getRulesWithoutPlaceholders(): array /** * Set the validation rules. + * + * @return $this */ public function setRules(array $rules): static { @@ -1715,6 +1721,7 @@ public function setRules(array $rules): static * rebuilds the complete original rule graph. * * @param list $attributes + * @return $this */ public function retainRules(array $attributes): static { @@ -1731,6 +1738,8 @@ public function retainRules(array $attributes): static /** * Append new validation rules to the validator. + * + * @return $this */ public function appendRules(array $rules): static { @@ -1768,6 +1777,8 @@ public function addRules(array $rules): void /** * Add conditions to a given field based on a Closure. + * + * @return $this */ public function sometimes(array|string $attribute, array|object|string $rules, callable $callback): static { @@ -1812,6 +1823,8 @@ private function dataForSometimesIteration(string $attribute, bool $removeLastSe /** * Instruct the validator to stop validating after the first rule failure. + * + * @return $this */ public function stopOnFirstFailure(bool $stopOnFirstFailure = true): static { @@ -1910,6 +1923,8 @@ public function addReplacer(string $rule, Closure|string $replacer): void /** * Set the custom messages for the validator. + * + * @return $this */ public function setCustomMessages(array $messages): static { @@ -1920,6 +1935,8 @@ public function setCustomMessages(array $messages): static /** * Set the custom attributes on the validator. + * + * @return $this */ public function setAttributeNames(array $attributes): static { @@ -1930,6 +1947,8 @@ public function setAttributeNames(array $attributes): static /** * Add custom attributes to the validator. + * + * @return $this */ public function addCustomAttributes(array $attributes): static { @@ -1940,6 +1959,8 @@ public function addCustomAttributes(array $attributes): static /** * Set the callback that used to format an implicit attribute. + * + * @return $this */ public function setImplicitAttributesFormatter(?callable $formatter = null): static { @@ -1950,6 +1971,8 @@ public function setImplicitAttributesFormatter(?callable $formatter = null): sta /** * Set the custom values on the validator. + * + * @return $this */ public function setValueNames(array $values): static { @@ -1960,6 +1983,8 @@ public function setValueNames(array $values): static /** * Add the custom values for the validator. + * + * @return $this */ public function addCustomValues(array $customValues): static { @@ -2019,6 +2044,7 @@ public function getException(): string|ValidationException * Set the exception to throw upon failed validation. * * @param class-string|Throwable $exception + * @return $this * * @throws InvalidArgumentException */ @@ -2039,6 +2065,7 @@ public function setException(string|Throwable $exception): static * Ensure exponents are within range using the given callback. * * @param callable(int, string, mixed): mixed $callback + * @return $this */ public function ensureExponentWithinAllowedRangeUsing(callable $callback): static { diff --git a/src/view/composer.json b/src/view/composer.json index 7ac51091b3..5fd3b495f7 100644 --- a/src/view/composer.json +++ b/src/view/composer.json @@ -39,8 +39,8 @@ "hypervel/macroable": "^0.4", "hypervel/reflection": "^0.4", "hypervel/support": "^0.4", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "suggest": { "hypervel/foundation": "Required for the @vite, @viteReactRefresh, and @fonts directives." diff --git a/src/view/src/Compilers/BladeCompiler.php b/src/view/src/Compilers/BladeCompiler.php index dbcaa74394..2fa557ddd4 100644 --- a/src/view/src/Compilers/BladeCompiler.php +++ b/src/view/src/Compilers/BladeCompiler.php @@ -1027,6 +1027,8 @@ public function getCustomDirectives(): array * * Boot-only. The callback persists on the singleton BladeCompiler for the * worker lifetime and runs on every subsequent template compilation. + * + * @return $this */ public function prepareStringsForCompilationUsing(callable $callback): static { diff --git a/src/view/src/Compilers/ComponentTagCompiler.php b/src/view/src/Compilers/ComponentTagCompiler.php index d34d643d04..81111facce 100644 --- a/src/view/src/Compilers/ComponentTagCompiler.php +++ b/src/view/src/Compilers/ComponentTagCompiler.php @@ -621,7 +621,7 @@ protected function getAttributesFromAttributeString(string $attributeString): ar } return [$attribute => $value]; - })->toArray(); + })->all(); } /** diff --git a/src/view/src/Component.php b/src/view/src/Component.php index 05f5c98c19..ec8f836db2 100644 --- a/src/view/src/Component.php +++ b/src/view/src/Component.php @@ -312,6 +312,8 @@ protected function ignoredMethods(): array /** * Set the component alias name. + * + * @return $this */ public function withName(string $name): static { @@ -322,6 +324,8 @@ public function withName(string $name): static /** * Set the extra attributes that the component should make available. + * + * @return $this */ public function withAttributes(array $attributes): static { diff --git a/src/view/src/ComponentSlot.php b/src/view/src/ComponentSlot.php index c81f386302..9a62123625 100644 --- a/src/view/src/ComponentSlot.php +++ b/src/view/src/ComponentSlot.php @@ -27,6 +27,8 @@ public function __construct( /** * Set the extra attributes that the slot should make available. + * + * @return $this */ public function withAttributes(array $attributes): static { diff --git a/src/view/src/Engines/CompilerEngine.php b/src/view/src/Engines/CompilerEngine.php index 311f415d8a..f33f53d707 100755 --- a/src/view/src/Engines/CompilerEngine.php +++ b/src/view/src/Engines/CompilerEngine.php @@ -5,6 +5,7 @@ namespace Hypervel\View\Engines; use Hypervel\Context\CoroutineContext; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\RecordNotFoundException; use Hypervel\Database\RecordsNotFoundException; use Hypervel\Filesystem\Filesystem; @@ -12,7 +13,6 @@ use Hypervel\Support\Stringable; use Hypervel\View\Compilers\CompilerInterface; use Hypervel\View\ViewException; -use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpKernel\Exception\HttpException; use Throwable; @@ -114,7 +114,7 @@ protected function popCompiledPath(): void */ protected function handleViewException(Throwable $e, int $obLevel): void { - if ($e instanceof CanceledException + if (Coroutine::causedByCancellation($e) || $e instanceof HttpException || $e instanceof HttpResponseException || $e instanceof RecordNotFoundException diff --git a/src/view/src/Factory.php b/src/view/src/Factory.php index 02b948075c..2f45b1a1a8 100755 --- a/src/view/src/Factory.php +++ b/src/view/src/Factory.php @@ -12,8 +12,8 @@ use Hypervel\Contracts\View\Engine; use Hypervel\Contracts\View\Factory as FactoryContract; use Hypervel\Contracts\View\View as ViewContract; -use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Arr; +use Hypervel\Support\FailureReporter; use Hypervel\Support\Traits\Macroable; use Hypervel\View\Engines\EngineResolver; use InvalidArgumentException; @@ -348,8 +348,8 @@ public function notifyRendering(ViewContract $view): void * Register a post-render observer. * * Boot-only. The observer is called after each view's engine completes, - * receiving the View instance and any ordinary rendering failure. Observers - * persist for the worker lifetime on this singleton. + * receiving the View instance and any rendering failure or cancellation. + * Observers persist for the worker lifetime on this singleton. */ public function observeRendered(callable $observer): void { @@ -366,6 +366,10 @@ public function hasRenderedObservers(): bool /** * Notify post-render observers that a view finished rendering. + * + * Every observer is notified, so each one that started can finish; a later + * observer's cancellation takes precedence over an earlier failure, and a + * displaced observer failure is reported. */ public function notifyRendered(ViewContract $view, ?Throwable $exception): void { @@ -375,11 +379,10 @@ public function notifyRendered(ViewContract $view, ?Throwable $exception): void try { $observer($view, $exception); } catch (Throwable $throwable) { - if (Coroutine::causedByCancellation($throwable)) { - throw $throwable; + // An observer that rethrows the rendering failure adds none; the renderer throws it. + if ($throwable !== $exception) { + $observerException = FailureReporter::preferCancellation($observerException, $throwable); } - - $observerException ??= $throwable; } } @@ -454,6 +457,8 @@ public function prependLocation(string $location): void * * Boot-only. The namespace persists on the singleton view finder for the * worker lifetime and affects every subsequent request. + * + * @return $this */ public function addNamespace(string $namespace, string|array $hints): static { @@ -467,6 +472,8 @@ public function addNamespace(string $namespace, string|array $hints): static * * Boot-only. The namespace persists on the singleton view finder for the * worker lifetime and affects every subsequent request. + * + * @return $this */ public function prependNamespace(string $namespace, string|array $hints): static { @@ -480,6 +487,8 @@ public function prependNamespace(string $namespace, string|array $hints): static * * Boot-only. The namespace persists on the singleton view finder for the * worker lifetime and affects every subsequent request. + * + * @return $this */ public function replaceNamespace(string $namespace, string|array $hints): static { diff --git a/src/view/src/FileViewFinder.php b/src/view/src/FileViewFinder.php index 467f9f33fc..bf77feb5d3 100755 --- a/src/view/src/FileViewFinder.php +++ b/src/view/src/FileViewFinder.php @@ -242,6 +242,8 @@ public function getFilesystem(): Filesystem * * Boot-only. Request-time use changes shared view paths while concurrent * coroutines may still be resolving views. + * + * @return $this */ public function setPaths(array $paths): static { diff --git a/src/view/src/View.php b/src/view/src/View.php index 87a764365a..e7252553c9 100755 --- a/src/view/src/View.php +++ b/src/view/src/View.php @@ -13,8 +13,8 @@ use Hypervel\Contracts\Support\Renderable; use Hypervel\Contracts\View\Engine; use Hypervel\Contracts\View\View as ViewContract; -use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Collection; +use Hypervel\Support\FailureReporter; use Hypervel\Support\HtmlString; use Hypervel\Support\MessageBag; use Hypervel\Support\Str; @@ -23,6 +23,9 @@ use Stringable; use Throwable; +/** + * @implements ArrayAccess + */ class View implements ArrayAccess, Htmlable, Stringable, ViewContract { use Macroable { @@ -74,7 +77,7 @@ public function fragments(?array $fragments = null): string */ public function fragmentIf(bool $boolean, string $fragment): string|Htmlable { - if (value($boolean)) { + if ($boolean) { return $this->fragment($fragment); } @@ -86,7 +89,7 @@ public function fragmentIf(bool $boolean, string $fragment): string|Htmlable */ public function fragmentsIf(bool $boolean, ?array $fragments = null): string { - if (value($boolean)) { + if ($boolean) { return $this->fragments($fragments); } @@ -182,17 +185,14 @@ protected function renderContentsWithObservers(): string $this->factory->notifyRendering($this); $contents = $this->getContents(); } catch (Throwable $throwable) { - if (Coroutine::causedByCancellation($throwable)) { - throw $throwable; - } - + // Cancellation is passed on too, so observers that started can finish. $exception = $throwable; } try { $this->factory->notifyRendered($this, $exception); } catch (Throwable $throwable) { - $exception = Coroutine::preferCancellation($exception, $throwable); + $exception = FailureReporter::preferCancellation($exception, $throwable); } if ($exception !== null) { @@ -258,6 +258,8 @@ public function renderSections(): array /** * Add a piece of data to the view. + * + * @return $this */ public function with(string|array $key, mixed $value = null): static { @@ -272,6 +274,8 @@ public function with(string|array $key, mixed $value = null): static /** * Add a view instance to the view data. + * + * @return $this */ public function nest(string $key, string $view, array $data = []): static { @@ -280,6 +284,8 @@ public function nest(string $key, string $view, array $data = []): static /** * Add validation errors to the view. + * + * @return $this */ public function withErrors(MessageProvider|array|string $provider, string $bag = 'default'): static { diff --git a/src/watcher/src/ServerRestartStrategy.php b/src/watcher/src/ServerRestartStrategy.php index 7168476890..50fca2a919 100644 --- a/src/watcher/src/ServerRestartStrategy.php +++ b/src/watcher/src/ServerRestartStrategy.php @@ -238,7 +238,7 @@ protected function runServer(): void try { $this->closeProcess($process); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } } } diff --git a/src/watcher/src/Watcher.php b/src/watcher/src/Watcher.php index c9c77e4a25..c04785333a 100644 --- a/src/watcher/src/Watcher.php +++ b/src/watcher/src/Watcher.php @@ -4,12 +4,12 @@ namespace Hypervel\Watcher; +use Hypervel\Coroutine\Coroutine as FrameworkCoroutine; use Hypervel\Coroutine\WaitGroup; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; use Hypervel\Watcher\Driver\DriverInterface; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Output\OutputInterface; use Throwable; @@ -37,9 +37,7 @@ public function run(): void try { $callback(); } catch (Throwable $throwable) { - if ($exception === null || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) { - $exception = $throwable; - } + $exception = FrameworkCoroutine::preferCancellation($exception, $throwable); } }; @@ -100,7 +98,7 @@ public function run(): void $capture(fn () => $this->strategy?->stop()); $capture(fn () => $channel->close()); - if (! $exception instanceof CanceledException) { + if ($exception === null || ! FrameworkCoroutine::causedByCancellation($exception)) { $capture(function () use ($driverStarted, $driverFinished): void { if ($driverStarted && ! $driverFinished->wait(1.0)) { throw new RuntimeException('The file watcher did not stop within one second.'); diff --git a/src/wayfinder/src/GenerateCommand.php b/src/wayfinder/src/GenerateCommand.php index 19823b5359..3443fab562 100644 --- a/src/wayfinder/src/GenerateCommand.php +++ b/src/wayfinder/src/GenerateCommand.php @@ -525,6 +525,7 @@ private function writeMultiRouteControllerMethodExport(Collection $routes, strin $duplicateUris = $routes->duplicates(fn (Route $route) => $route->uri()); + // @phpstan-ignore argument.type (handle() registers the wayfinder view namespace when the command runs.) $this->appendContent($path, $this->view->make('wayfinder::multi-method', [ 'method' => $method, 'original_method' => $routes->first()->originalJsMethod(), @@ -556,6 +557,7 @@ private function writeMultiRouteControllerMethodExport(Collection $routes, strin */ private function writeControllerMethodExport(Route $route, string $path, string $method): void { + // @phpstan-ignore argument.type (handle() registers the wayfinder view namespace when the command runs.) $this->appendContent($path, $this->view->make('wayfinder::method', [ 'controller' => $route->controller(), 'method' => $method, @@ -627,6 +629,7 @@ private function appendCommonImports(Collection $routes, string $path, string $n */ private function writeNamedMethodExport(Route $route, string $path, string $method): void { + // @phpstan-ignore argument.type (handle() registers the wayfinder view namespace when the command runs.) $this->appendContent($path, $this->view->make('wayfinder::method', [ 'controller' => $route->controller(), 'method' => $method, diff --git a/src/wayfinder/src/Route.php b/src/wayfinder/src/Route.php index b5cb0a2e66..d75d7cc575 100644 --- a/src/wayfinder/src/Route.php +++ b/src/wayfinder/src/Route.php @@ -340,7 +340,7 @@ private function rawUri(): string } $uri = str($uri) - ->replace($defaultParams->keys()->toArray(), $defaultParams->values()->toArray()) + ->replace($defaultParams->keys()->all(), $defaultParams->values()->all()) ->toString(); if ($uri !== '/') { diff --git a/src/websocket-server/composer.json b/src/websocket-server/composer.json index 1f40a9ba95..0149be5d71 100644 --- a/src/websocket-server/composer.json +++ b/src/websocket-server/composer.json @@ -44,8 +44,8 @@ "hypervel/routing": "^0.4", "hypervel/server": "^0.4", "hypervel/support": "^0.4", - "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1" + "symfony/http-foundation": "^8.1.11", + "symfony/http-kernel": "^8.1.11" }, "config": { "sort-packages": true diff --git a/src/websocket-server/src/Server.php b/src/websocket-server/src/Server.php index 92ac47ee53..aa4cf78c5c 100644 --- a/src/websocket-server/src/Server.php +++ b/src/websocket-server/src/Server.php @@ -22,6 +22,7 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Http\FdGetter; use Hypervel\Http\Request as HttpRequest; +use Hypervel\HttpServer\Events\RequestCanceled; use Hypervel\HttpServer\Events\RequestHandled; use Hypervel\HttpServer\Events\RequestReceived; use Hypervel\HttpServer\Events\ResponseSent; @@ -30,6 +31,7 @@ use Hypervel\Routing\Router; use Hypervel\Support\Defer\DeferredCallback; use Hypervel\Support\Defer\DeferredCallbackCollection; +use Hypervel\Support\FailureReporter; use Hypervel\Support\SafeCaller; use Hypervel\WebSocketServer\Collector\FdCollector; use Hypervel\WebSocketServer\Context as WebSocketContext; @@ -111,6 +113,8 @@ public function onHandshake(Request $request, SwooleResponse $response): void $handshake = null; $observedException = null; $terminalException = null; + $httpResponse = null; + $responseSent = false; try { try { @@ -168,9 +172,11 @@ public function onHandshake(Request $request, SwooleResponse $response): void $server = $this->getServer(); $handshake = [$fd, $class, $instance, $server]; } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $observedException = $throwable; $httpResponse = $this->container->make(SafeCaller::class)->call( fn () => $this->handleException($throwable), @@ -191,21 +197,27 @@ public function onHandshake(Request $request, SwooleResponse $response): void server: $this->serverName, )); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $terminalException = $throwable; } } try { ResponseBridge::send($httpResponse, $response, request: $httpRequest); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { - $terminalException ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + + $terminalException = FailureReporter::preferCancellation($terminalException, $throwable); } + $responseSent = true; + if ($httpRequest !== null) { try { if ($this->event?->hasListeners(ResponseSent::class)) { @@ -216,10 +228,12 @@ public function onHandshake(Request $request, SwooleResponse $response): void server: $this->serverName, )); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { - $terminalException ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + + $terminalException = FailureReporter::preferCancellation($terminalException, $throwable); } } @@ -240,10 +254,12 @@ public function onHandshake(Request $request, SwooleResponse $response): void if ($httpRequest !== null) { try { $this->terminateRouteMiddleware($httpRequest, $httpResponse); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { - $terminalException ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + + $terminalException = FailureReporter::preferCancellation($terminalException, $throwable); } } @@ -255,6 +271,22 @@ public function onHandshake(Request $request, SwooleResponse $response): void if ($terminalException !== null) { throw $terminalException; } + } catch (Throwable $throwable) { + if ($httpRequest !== null && Coroutine::causedByCancellation($throwable)) { + // The native boundary would have reported this failure if cancellation had not displaced it. + if ($terminalException !== null) { + FailureReporter::report($terminalException); + } + + throw $this->requestCanceled( + $httpRequest, + $responseSent ? $httpResponse : null, + $observedException ?? $terminalException, + $throwable, + ); + } + + throw $throwable; } finally { if ($fd !== null && ! $committed) { FdCollector::del($fd); @@ -308,18 +340,26 @@ public function onMessage(WebSocketServer $server, Frame $frame): void if ($this->event?->hasListeners(MessageReceived::class)) { $this->event->dispatch(new MessageReceived($fd, $frame, $this->serverName)); } - } catch (CanceledException) { - return; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->messageCanceled($fd, $frame, $throwable); + + return; + } + $exception = $throwable; $this->reportCallbackFailure($throwable); } try { $instance->onMessage($server, $frame); - } catch (CanceledException) { - return; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->messageCanceled($fd, $frame, $throwable, $exception); + + return; + } + $exception ??= $throwable; $this->reportCallbackFailure($throwable); } @@ -328,11 +368,15 @@ public function onMessage(WebSocketServer $server, Frame $frame): void if ($this->event?->hasListeners(MessageHandled::class)) { $this->event->dispatch(new MessageHandled($fd, $frame, $this->serverName, $exception)); } - } catch (CanceledException) { - return; } catch (Throwable $throwable) { - $exception ??= $throwable; $this->reportCallbackFailure($throwable); + + // Completion is already published; cancellation only skips the deferred callbacks. + if (Coroutine::causedByCancellation($throwable)) { + return; + } + + $exception ??= $throwable; } $this->invokeDeferredCallbacks($exception === null); @@ -356,27 +400,39 @@ public function onClose(SwooleServer $server, int $fd, int $reactorId): void if ($this->event?->hasListeners(ConnectionClosing::class)) { $this->event->dispatch(new ConnectionClosing($fd, $reactorId, $this->serverName)); } - } catch (CanceledException) { - return; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->closeCanceled($fd, $reactorId, $throwable); + + return; + } + $failed = true; $this->reportCallbackFailure($throwable); } try { $this->logger->debug(sprintf('WebSocket: fd[%d] closed.', $fd)); - } catch (CanceledException) { - return; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->closeCanceled($fd, $reactorId, $throwable); + + return; + } + $failed = true; $this->reportCallbackFailure($throwable); } try { $instance = $this->container->make($class); - } catch (CanceledException) { - return; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->closeCanceled($fd, $reactorId, $throwable); + + return; + } + $failed = true; $this->reportCallbackFailure($throwable); $instance = null; @@ -385,9 +441,13 @@ public function onClose(SwooleServer $server, int $fd, int $reactorId): void if ($instance instanceof OnCloseInterface) { try { $instance->onClose($server, $fd, $reactorId); - } catch (CanceledException) { - return; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->closeCanceled($fd, $reactorId, $throwable); + + return; + } + $failed = true; $this->reportCallbackFailure($throwable); } @@ -397,11 +457,15 @@ public function onClose(SwooleServer $server, int $fd, int $reactorId): void if ($this->event?->hasListeners(ConnectionClosed::class)) { $this->event->dispatch(new ConnectionClosed($fd, $reactorId, $this->serverName)); } - } catch (CanceledException) { - return; } catch (Throwable $throwable) { - $failed = true; $this->reportCallbackFailure($throwable); + + // Completion is already published; cancellation only skips the deferred callbacks. + if (Coroutine::causedByCancellation($throwable)) { + return; + } + + $failed = true; } $this->invokeDeferredCallbacks(! $failed); @@ -423,12 +487,12 @@ protected function handleException(Throwable $throwable): Response // resolution or handling carries it as that failure's previous. The // return suppresses it once a response exists. try { - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ throw $throwable; } finally { $handler = $this->container->make(WebSocketExceptionHandler::class); - /* @phpstan-ignore finally.exitPoint */ + /* @phpstan-ignore finally.exitPoint (preserves exception chaining until handling succeeds) */ return $handler->handle($throwable, new Response); } } @@ -511,9 +575,13 @@ protected function deferOnOpen( if ($this->event?->hasListeners(ConnectionOpened::class)) { $this->event->dispatch(new ConnectionOpened($fd, $request, $this->serverName)); } - } catch (CanceledException) { - return; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->openCanceled($httpRequest, $httpResponse, $throwable); + + return; + } + $failed = true; $this->reportCallbackFailure($throwable); } @@ -521,9 +589,13 @@ protected function deferOnOpen( if ($instance instanceof OnOpenInterface) { try { $instance->onOpen($server, $request); - } catch (CanceledException) { - return; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->openCanceled($httpRequest, $httpResponse, $throwable); + + return; + } + $failed = true; $this->reportCallbackFailure($throwable); } @@ -533,9 +605,13 @@ protected function deferOnOpen( // has initialized the connection. Drain handshake defers afterward. try { $this->terminateRouteMiddleware($httpRequest, $httpResponse); - } catch (CanceledException) { - return; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $this->openCanceled($httpRequest, $httpResponse, $throwable); + + return; + } + $failed = true; $this->reportCallbackFailure($throwable); } @@ -570,9 +646,11 @@ private function terminateRouteMiddleware(HttpRequest $request, Response $respon if (method_exists($instance, 'terminate')) { $instance->terminate($request, $response); } - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } @@ -582,6 +660,82 @@ private function terminateRouteMiddleware(HttpRequest $request, Response $respon } } + /** + * Complete a message whose handling cancellation stopped, without escaping + * the native callback boundary. + * + * An earlier failure stays the message's outcome; a cleanup failure carrying + * the cancellation is reported. + */ + private function messageCanceled(int $fd, Frame $frame, Throwable $cancellation, ?Throwable $exception = null): void + { + $this->reportCallbackFailure($cancellation); + + try { + if ($this->event?->hasListeners(MessageHandled::class)) { + $this->event->dispatch(new MessageHandled($fd, $frame, $this->serverName, $exception ?? $cancellation)); + } + } catch (Throwable $throwable) { + $this->reportCallbackFailure($throwable); + } + } + + /** + * Notify listeners that the connection closed although cancellation stopped + * an earlier close step, without escaping the native callback boundary. + */ + private function closeCanceled(int $fd, int $reactorId, Throwable $cancellation): void + { + $this->reportCallbackFailure($cancellation); + + try { + if ($this->event?->hasListeners(ConnectionClosed::class)) { + $this->event->dispatch(new ConnectionClosed($fd, $reactorId, $this->serverName)); + } + } catch (Throwable $throwable) { + $this->reportCallbackFailure($throwable); + } + } + + /** + * Complete a handshake request whose connection opening cancellation stopped, + * without escaping the native callback boundary. + */ + private function openCanceled(HttpRequest $request, Response $response, Throwable $cancellation): void + { + $this->reportCallbackFailure($cancellation); + $this->requestCanceled($request, $response, null, $cancellation); + } + + /** + * Notify listeners that cancellation stopped a handshake request's lifecycle. + * + * Returns the throwable to propagate, so a listener failure never replaces the cancellation. + */ + protected function requestCanceled( + HttpRequest $request, + ?Response $response, + ?Throwable $exception, + Throwable $cancellation, + ): Throwable { + try { + if ($this->event?->hasListeners(RequestCanceled::class)) { + $this->event->dispatch(new RequestCanceled( + request: $request, + response: $response, + cancellation: $cancellation, + exception: $exception, + server: $this->serverName, + )); + } + } catch (Throwable $throwable) { + // The first cancellation keeps precedence; a listener failure is still reported. + $this->reportCallbackFailure($throwable); + } + + return $cancellation; + } + /** * Run deferred work without escaping the native callback boundary. */ @@ -595,7 +749,6 @@ private function invokeDeferredCallbacks(bool $successful): void fn (DeferredCallback $callback): bool => $successful || $callback->always ); } - } catch (CanceledException) { } catch (Throwable $throwable) { $this->reportCallbackFailure($throwable); } @@ -603,9 +756,16 @@ private function invokeDeferredCallbacks(bool $successful): void /** * Report a WebSocket callback failure without escaping the native boundary. + * + * A direct cancellation is control flow and is not reported; a cleanup failure carrying it is. */ protected function reportCallbackFailure(Throwable $throwable): void { + // @phpstan-ignore hypervel.cancellation.exactType (a direct cancellation is never reported; a failure carrying it is reported) + if ($throwable instanceof CanceledException) { + return; + } + try { $this->container->make(ExceptionHandlerContract::class)->report($throwable); diff --git a/src/workbench/composer.json b/src/workbench/composer.json index f6c3b40838..1b9bf41c74 100644 --- a/src/workbench/composer.json +++ b/src/workbench/composer.json @@ -46,7 +46,7 @@ "hypervel/validation": "^0.4", "hypervel/view": "^0.4", "symfony/console": "^8.1.10", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1.11" }, "autoload": { "psr-4": { diff --git a/src/workbench/routes/web.php b/src/workbench/routes/web.php index 6a73878c8a..b449c1dd6e 100644 --- a/src/workbench/routes/web.php +++ b/src/workbench/routes/web.php @@ -11,6 +11,7 @@ // }); Route::get('/dashboard', function (): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard'); diff --git a/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php b/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php index b1bde2aa3b..6d302ecfb2 100644 --- a/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/src/workbench/src/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -18,6 +18,7 @@ class AuthenticatedSessionController extends Controller */ public function create(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('auth.login'); } @@ -38,7 +39,6 @@ public function store(LoginRequest $request): RedirectResponse */ public function destroy(Request $request): RedirectResponse { - /* @phpstan-ignore method.notFound */ Auth::guard()->logout(); $request->session()->invalidate(); diff --git a/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php b/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php index 4aaffc5ce6..89f52bd703 100644 --- a/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php +++ b/src/workbench/src/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -18,6 +18,7 @@ class ConfirmablePasswordController extends Controller */ public function show(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('auth.confirm-password'); } @@ -28,7 +29,7 @@ public function store(Request $request): RedirectResponse { if (! Auth::guard()->validate([ 'email' => $request->user()->email, - 'password' => $request->password, // @phpstan-ignore property.notFound + 'password' => $request->password, ])) { throw ValidationException::withMessages([ 'password' => __('auth.password'), diff --git a/src/workbench/src/Http/Controllers/Auth/EmailVerificationPromptController.php b/src/workbench/src/Http/Controllers/Auth/EmailVerificationPromptController.php index 5a23c7e510..2ac9cbf920 100644 --- a/src/workbench/src/Http/Controllers/Auth/EmailVerificationPromptController.php +++ b/src/workbench/src/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -18,6 +18,7 @@ public function __invoke(Request $request): RedirectResponse|View { return $request->user()->hasVerifiedEmail() ? redirect()->intended(route('dashboard', absolute: false)) + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) : view('auth.verify-email'); } } diff --git a/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php b/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php index 63e92baade..76fd27251f 100644 --- a/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php +++ b/src/workbench/src/Http/Controllers/Auth/NewPasswordController.php @@ -26,6 +26,7 @@ class NewPasswordController extends Controller */ public function create(Request $request): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('auth.reset-password', ['request' => $request]); } @@ -49,7 +50,7 @@ public function store(Request $request): RedirectResponse $request->only('email', 'password', 'password_confirmation', 'token'), function (Authenticatable&CanResetPassword&Model $user) use ($request): void { $user->forceFill([ - 'password' => Hash::make($request->password), // @phpstan-ignore property.notFound + 'password' => Hash::make($request->password), ]); // The model names its remember token column, or has none. diff --git a/src/workbench/src/Http/Controllers/Auth/PasswordResetLinkController.php b/src/workbench/src/Http/Controllers/Auth/PasswordResetLinkController.php index 24d0f1d879..b59e765182 100644 --- a/src/workbench/src/Http/Controllers/Auth/PasswordResetLinkController.php +++ b/src/workbench/src/Http/Controllers/Auth/PasswordResetLinkController.php @@ -18,6 +18,7 @@ class PasswordResetLinkController extends Controller */ public function create(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('auth.forgot-password'); } diff --git a/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php b/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php index b0e5e4cb47..c1d32d1a04 100644 --- a/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php +++ b/src/workbench/src/Http/Controllers/Auth/RegisteredUserController.php @@ -26,6 +26,7 @@ class RegisteredUserController extends Controller */ public function create(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('auth.register'); } @@ -48,9 +49,9 @@ public function store(Request $request): RedirectResponse ]); $user = $userModel::forceCreate([ - 'name' => $request->name, // @phpstan-ignore property.notFound - 'email' => $request->email, // @phpstan-ignore property.notFound - 'password' => Hash::make($request->password), // @phpstan-ignore property.notFound + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), ]); if (Event::hasListeners(Registered::class)) { diff --git a/src/workbench/src/Http/Controllers/ProfileController.php b/src/workbench/src/Http/Controllers/ProfileController.php index abe666d9a9..2c5d06517f 100644 --- a/src/workbench/src/Http/Controllers/ProfileController.php +++ b/src/workbench/src/Http/Controllers/ProfileController.php @@ -18,6 +18,7 @@ class ProfileController extends Controller */ public function edit(Request $request): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('profile.edit', [ 'user' => $request->user(), ]); diff --git a/src/workbench/src/Http/Controllers/WorkbenchController.php b/src/workbench/src/Http/Controllers/WorkbenchController.php index 86439d5ff2..f0d53679ce 100644 --- a/src/workbench/src/Http/Controllers/WorkbenchController.php +++ b/src/workbench/src/Http/Controllers/WorkbenchController.php @@ -57,7 +57,7 @@ public function login(string $userId, ?string $guard = null): RedirectResponse $guard = $guard ?: config()->string('auth.defaults.guard'); /** @var UserProvider $provider */ - $provider = Auth::guard($guard)->getProvider(); // @phpstan-ignore method.notFound + $provider = Auth::guard($guard)->getProvider(); $user = Str::contains($userId, '@') ? $provider->retrieveByCredentials(['email' => $userId]) @@ -69,7 +69,6 @@ public function login(string $userId, ?string $guard = null): RedirectResponse // user, such as a password confirmation, carries over. Session::flush(); - /* @phpstan-ignore method.notFound */ Auth::guard($guard)->login($user); return redirect(Workbench::config('start')); @@ -82,7 +81,6 @@ public function logout(?string $guard = null): RedirectResponse { $guard = $guard ?: config()->string('auth.defaults.guard'); - /* @phpstan-ignore method.notFound */ Auth::guard($guard)->logout(); Session::invalidate(); diff --git a/src/workbench/src/View/Components/AppLayout.php b/src/workbench/src/View/Components/AppLayout.php index dd76e07117..7546b5e991 100644 --- a/src/workbench/src/View/Components/AppLayout.php +++ b/src/workbench/src/View/Components/AppLayout.php @@ -14,6 +14,7 @@ class AppLayout extends Component */ public function render(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('layouts.app'); } } diff --git a/src/workbench/src/View/Components/GuestLayout.php b/src/workbench/src/View/Components/GuestLayout.php index 68efc871a2..7eba5eb451 100644 --- a/src/workbench/src/View/Components/GuestLayout.php +++ b/src/workbench/src/View/Components/GuestLayout.php @@ -14,6 +14,7 @@ class GuestLayout extends Component */ public function render(): View { + // @phpstan-ignore argument.type (AuthServiceProvider adds the Workbench views only when Workbench authentication is enabled.) return view('layouts.guest'); } } diff --git a/tests/Ai/Fixtures/helpers.php b/tests/Ai/Fixtures/helpers.php index 014c5c2235..eaa74814d3 100644 --- a/tests/Ai/Fixtures/helpers.php +++ b/tests/Ai/Fixtures/helpers.php @@ -10,13 +10,14 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Http\Client\Request; +use Hypervel\Support\Env; use Hypervel\Support\Facades\Http; use PHPUnit\Framework\Assert; function requiresApiKey(string ...$keys): void { foreach ($keys as $key) { - if (empty(env($key))) { + if (empty(Env::get($key))) { Assert::markTestSkipped("Missing {$key} — skipping external test."); } } diff --git a/tests/Auth/CacheTokenRepositoryTest.php b/tests/Auth/CacheTokenRepositoryTest.php index 2edf63f72b..7cf8e56575 100644 --- a/tests/Auth/CacheTokenRepositoryTest.php +++ b/tests/Auth/CacheTokenRepositoryTest.php @@ -5,8 +5,8 @@ namespace Hypervel\Tests\Auth; use Hypervel\Auth\Passwords\CacheTokenRepository; -use Hypervel\Cache\Repository; use Hypervel\Contracts\Auth\CanResetPassword; +use Hypervel\Contracts\Cache\Repository; use Hypervel\Contracts\Hashing\Hasher; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; diff --git a/tests/Auth/VerifiedEventTest.php b/tests/Auth/VerifiedEventTest.php new file mode 100644 index 0000000000..92cb5e956d --- /dev/null +++ b/tests/Auth/VerifiedEventTest.php @@ -0,0 +1,22 @@ +assertNotInstanceOf(Authenticatable::class, $user); + $this->assertSame($user, (new Verified($user))->user); + } +} diff --git a/tests/Bus/BusPendingBatchTest.php b/tests/Bus/BusPendingBatchTest.php index 1c7cde0d56..2198be299f 100644 --- a/tests/Bus/BusPendingBatchTest.php +++ b/tests/Bus/BusPendingBatchTest.php @@ -17,6 +17,8 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Coroutine\Coroutine; use Hypervel\Events\Dispatcher as EventsDispatcher; +use Hypervel\Queue\Attributes\Connection; +use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Support\Collection; use Hypervel\Support\Testing\Fakes\EventFake; use Hypervel\Tests\TestCase; @@ -241,6 +243,36 @@ public function testChainedBatchRemainderInheritsEmptyIdentifiersAndPreservesZer } } + public function testChainedBatchRemainderKeepsJobAttributesOverChainDefaults(): void + { + $container = new Container; + Container::setInstance($container); + + $sourceJob = new BatchableJob; + $chainedBatch = new TestableChainedBatch(new PendingBatch($container, new Collection([$sourceJob]))); + $chainedBatch->chain([new AttributedChainedBatchQueueableJob]); + $chainedBatch->chainConnection = 'chain-connection'; + $chainedBatch->chainQueue = 'chain-queue'; + + $dispatcher = m::mock(BusDispatcher::class); + $dispatcher->expects('dispatch')->with(m::on(function (AttributedChainedBatchQueueableJob $job): bool { + $this->assertNull($job->connection); + $this->assertNull($job->queue); + $this->assertSame('chain-connection', $job->chainConnection); + $this->assertSame('chain-queue', $job->chainQueue); + + return true; + })); + $container->instance(BusDispatcher::class, $dispatcher); + + $callbacks = $chainedBatch->attachRemainder(new PendingBatch($container, new Collection([$sourceJob]))) + ->finallyCallbacks(); + + $batch = m::mock(Batch::class); + $batch->expects('cancelled')->andReturnFalse(); + $callbacks[0]($batch); + } + public function testPendingBatchMayBeConfiguredAndDispatched(): void { $container = new Container; @@ -520,6 +552,21 @@ public function testItThrowsAnExceptionIfBatchedJobContainsBatchWithNonbatchable ); } + public function testItThrowsExceptionIfChainedJobAfterClosureIsNotBatchable(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not use the Batchable trait'); + + new PendingBatch(new Container, new Collection([ + [ + function (): void { + }, + new class { + }, + ], + ])); + } + public function testItCanBatchAClosure(): void { new PendingBatch( @@ -691,6 +738,13 @@ class ChainedBatchQueueableJob use Queueable; } +#[Connection('job-connection')] +#[QueueAttribute('job-queue')] +class AttributedChainedBatchQueueableJob +{ + use Queueable; +} + class TestableChainedBatch extends ChainedBatch { /** diff --git a/tests/Bus/QueueableTest.php b/tests/Bus/QueueableTest.php index 57038ca414..9bd044d798 100644 --- a/tests/Bus/QueueableTest.php +++ b/tests/Bus/QueueableTest.php @@ -7,6 +7,8 @@ use Hypervel\Bus\Queueable; use Hypervel\Container\Container; use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Queue\Attributes\Connection; +use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Tests\TestCase; use Laravel\SerializableClosure\SerializableClosure; use Mockery as m; @@ -185,6 +187,27 @@ public function testDispatchNextJobInheritsChainIdentifiersForEmptyStrings(): vo $job->dispatchNextJobInChain(); } + + public function testDispatchNextJobKeepsItsAttributesOverChainDefaults(): void + { + $job = new FakeJob; + $job->chain([new AttributedFakeJob]); + $job->chainConnection = 'default-connection'; + $job->chainQueue = 'default-queue'; + + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->once()->with(m::on(function (AttributedFakeJob $next): bool { + $this->assertNull($next->connection); + $this->assertNull($next->queue); + $this->assertSame('default-connection', $next->chainConnection); + $this->assertSame('default-queue', $next->chainQueue); + + return true; + })); + Container::getInstance()->instance(Dispatcher::class, $dispatcher); + + $job->dispatchNextJobInChain(); + } } class FakeJob @@ -192,6 +215,13 @@ class FakeJob use Queueable; } +#[Connection('job-connection')] +#[QueueAttribute('job-queue')] +class AttributedFakeJob +{ + use Queueable; +} + enum ConnectionEnum: string { case Sqs = 'sqs'; diff --git a/tests/ConnectionPool/ConnectionPoolTest.php b/tests/ConnectionPool/ConnectionPoolTest.php index 2e68fb854a..338df33379 100644 --- a/tests/ConnectionPool/ConnectionPoolTest.php +++ b/tests/ConnectionPool/ConnectionPoolTest.php @@ -729,9 +729,11 @@ public function testHealthyIdleCheckRequeuesConnection(): void } #[DataProvider('idleCheckCleanupFailures')] - public function testCanceledIdleCheckDisposesOnceAndPreservesCancellation(?string $cleanupFailure): void + public function testCanceledIdleCheckDisposesOnceAndPreservesCancellation(?string $cleanupFailure, bool $carried = false): void { - $cancellation = new CanceledException('idle check canceled'); + $cancellation = $carried + ? new RuntimeException('idle check failed', previous: new CanceledException('idle check canceled')) + : new CanceledException('idle check canceled'); $secondary = match ($cleanupFailure) { 'canceled' => new CanceledException('close canceled'), 'failed' => new RuntimeException('close failed'), @@ -757,7 +759,7 @@ public function testCanceledIdleCheckDisposesOnceAndPreservesCancellation(?strin try { $pool->checkIdleConnection(); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $caught = $exception; } finally { $pool->close(); @@ -771,7 +773,7 @@ public function testCanceledIdleCheckDisposesOnceAndPreservesCancellation(?strin public static function idleCheckCleanupFailures(): array { - return [[null], ['canceled'], ['failed']]; + return [[null], ['canceled'], ['failed'], [null, true]]; } public function testIdleDisposalCancellationPropagatesWithoutSecondDestruction(): void diff --git a/tests/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Console/Scheduling/ScheduleRunCommandTest.php index dbe13f8dc1..72da3310ef 100644 --- a/tests/Console/Scheduling/ScheduleRunCommandTest.php +++ b/tests/Console/Scheduling/ScheduleRunCommandTest.php @@ -8,6 +8,7 @@ use Closure; use Hypervel\Console\Commands\ScheduleRunCommand; use Hypervel\Console\Events\ScheduledBackgroundTaskFinished; +use Hypervel\Console\Events\ScheduledTaskCanceled; use Hypervel\Console\Events\ScheduledTaskFailed; use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Console\Events\ScheduledTaskSkipped; @@ -193,7 +194,7 @@ public static function deferredTaskOutcomes(): array #[TestWith([false])] #[TestWith([true])] - public function testCanceledCallbackReleasesItsMutexWithoutRunningFailureOrDeferredCallbacks(bool $cleanupThrows): void + public function testCanceledCallbackReleasesItsMutexAndPublishesOnlyTheCancellation(bool $cleanupThrows): void { $calls = []; $cancellation = new CanceledException('Task canceled'); @@ -228,13 +229,18 @@ public function testCanceledCallbackReleasesItsMutexWithoutRunningFailureOrDefer } $this->assertSame([], $calls); - $this->assertCount(1, $this->dispatched); + $this->assertCount(2, $this->dispatched); $this->assertInstanceOf(ScheduledTaskStarting::class, $this->dispatched[0]); + $this->assertInstanceOf(ScheduledTaskCanceled::class, $this->dispatched[1]); + $this->assertSame($cleanup ?? $cancellation, $this->dispatched[1]->cancellation); + $this->assertNull($this->dispatched[1]->runtime); + $this->assertNull($this->dispatched[1]->exception); } - public function testStartingListenerFailureRunsOnlyAlwaysCallbacksAndPreservesTheException(): void + public function testStartingListenerFailureIsPublishedWithoutARuntimeAndRunsOnlyAlwaysCallbacks(): void { $calls = []; + $failed = []; $exception = new RuntimeException('Starting listener failed'); $this->dispatcher = $this->app->make(Dispatcher::class); $this->dispatcher->listen(ScheduledTaskStarting::class, function () use (&$calls, $exception): void { @@ -247,6 +253,9 @@ public function testStartingListenerFailureRunsOnlyAlwaysCallbacksAndPreservesTh throw $exception; }); + $this->dispatcher->listen(ScheduledTaskFailed::class, function (ScheduledTaskFailed $event) use (&$failed): void { + $failed[] = $event; + }); $event = new CallbackEvent(m::mock(EventMutex::class), static fn (): bool => true); $caught = null; @@ -258,6 +267,66 @@ public function testStartingListenerFailureRunsOnlyAlwaysCallbacksAndPreservesTh $this->assertSame($exception, $caught); $this->assertSame(['always'], $calls); + $this->assertCount(1, $failed); + $this->assertSame($exception, $failed[0]->exception); + $this->assertNull($failed[0]->runtime); + } + + #[TestWith([false], 'starting listener canceled')] + #[TestWith([true], 'failure listener canceled')] + public function testPreRunCancellationIsPublishedWithAnyEarlierFailure(bool $afterFailure): void + { + $cancellation = new CanceledException; + $failure = $afterFailure ? new RuntimeException('Starting listener failed') : null; + $this->dispatcher = m::mock(Dispatcher::class); + $this->dispatcher->shouldReceive('hasListeners')->andReturnTrue(); + $this->dispatcher->shouldReceive('dispatch')->andReturnUsing(function (object $event) use ($cancellation, $failure): void { + $this->dispatched[] = $event; + + if ($event instanceof ScheduledTaskStarting) { + throw $failure ?? $cancellation; + } + + if ($event instanceof ScheduledTaskFailed) { + throw $cancellation; + } + }); + $this->handler->shouldNotReceive('report'); + + try { + $this->invokeRunEvents($this->makeCommand(), [new CallbackEvent(m::mock(EventMutex::class), static fn (): bool => true)]); + $this->fail('Expected the owned task to report cancellation.'); + } catch (ChildCancellationException $exception) { + $this->assertSame($cancellation, $exception->getPrevious()); + } + + $canceled = array_pop($this->dispatched); + $this->assertInstanceOf(ScheduledTaskCanceled::class, $canceled); + $this->assertSame($cancellation, $canceled->cancellation); + $this->assertNull($canceled->runtime); + $this->assertSame($failure, $canceled->exception); + $this->assertInstanceOf(ScheduledTaskStarting::class, $this->dispatched[0]); + $this->assertCount($afterFailure ? 2 : 1, $this->dispatched); + } + + public function testCanceledListenerFailureIsReportedWithoutReplacingTheCancellation(): void + { + $cancellation = new CanceledException; + $listenerFailure = new RuntimeException('Canceled listener failed'); + $this->dispatcher = m::mock(Dispatcher::class); + $this->dispatcher->shouldReceive('hasListeners')->andReturnTrue(); + $this->dispatcher->shouldReceive('dispatch')->andReturnUsing(function (object $event) use ($cancellation, $listenerFailure): void { + throw $event instanceof ScheduledTaskStarting ? $cancellation : $listenerFailure; + }); + $this->app->instance(ExceptionHandler::class, $this->handler); + $this->handler->shouldReceive('report')->once()->with($listenerFailure); + + try { + $this->invokeRunEvents($this->makeCommand(), [new CallbackEvent(m::mock(EventMutex::class), static fn (): bool => true)]); + $this->fail('Expected the owned task to report cancellation.'); + } catch (ChildCancellationException $exception) { + $this->assertSame($cancellation, $exception->getPrevious()); + } } #[TestWith([false])] diff --git a/tests/Coroutine/ParallelTest.php b/tests/Coroutine/ParallelTest.php index 28a184e604..60a8228a5f 100644 --- a/tests/Coroutine/ParallelTest.php +++ b/tests/Coroutine/ParallelTest.php @@ -8,6 +8,7 @@ use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Context\NonCopyableContext; +use Hypervel\Context\ReplicableContext; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\Exceptions\ChannelClosedException; @@ -1120,6 +1121,49 @@ public function testContextReplicationFailureDoesNotStrandParallelBookkeeping(): } } + public function testWrappedCancellationWhileCopyingContextCancelsStartedChildren(): void + { + $blocker = new Channel(1); + $childCoroutineId = null; + $childCancellation = null; + $outcome = null; + + $runner = Coroutine::create(static function () use ($blocker, &$childCoroutineId, &$childCancellation, &$outcome): void { + CoroutineContext::set('canceling', new ParallelTestCancelingContext); + $parallel = new Parallel(copyContext: true); + $parallel->add(static function () use ($blocker, &$childCoroutineId, &$childCancellation): void { + $childCoroutineId = Coroutine::id(); + + try { + $blocker->pop(); + } catch (CanceledException $exception) { + $childCancellation = $exception; + } + }, 'first'); + $parallel->add(static fn (): string => 'never', 'second'); + + try { + $parallel->wait(); + } catch (Throwable $throwable) { + $outcome = $throwable; + } + }); + + try { + $this->assertInstanceOf(RuntimeException::class, $outcome); + $this->assertSame('Copying the context was canceled.', $outcome->getMessage()); + $this->assertInstanceOf(CanceledException::class, $childCancellation); + $this->assertIsInt($childCoroutineId); + $this->assertFalse(Coroutine::exists($childCoroutineId)); + } finally { + foreach ([$runner, $childCoroutineId] as $coroutineId) { + if ($coroutineId !== null && Coroutine::exists($coroutineId)) { + SwooleCoroutine::cancel($coroutineId, true); + } + } + } + } + public function returnCoroutineId(): int { return Coroutine::id(); @@ -1133,3 +1177,20 @@ public function closeConcurrencyChannelForTest(): void $this->concurrentChannel?->close(); } } + +class ParallelTestCancelingContext implements ReplicableContext +{ + private int $replications = 0; + + /** + * Copy the context once, then fail with a cleanup error that carries cancellation. + */ + public function replicate(): static + { + if (++$this->replications > 1) { + throw new RuntimeException('Copying the context was canceled.', previous: new CanceledException('Canceled while copying the context.')); + } + + return new static; + } +} diff --git a/tests/Data/Support/DataTypeFactoryTest.php b/tests/Data/Support/DataTypeFactoryTest.php index 71e635e930..53b5ced0ef 100644 --- a/tests/Data/Support/DataTypeFactoryTest.php +++ b/tests/Data/Support/DataTypeFactoryTest.php @@ -364,6 +364,17 @@ public function testInheritedPhpDocKeywordsUseTheirPhpScopes(): void ); } + public function testParentWithoutAParentClassRemainsUnresolved(): void + { + $nativeType = $this->property('unresolvedNativeParent')->getNamedTypes()[0]; + $phpDocType = $this->property('unresolvedParent')->getNamedTypes()[0]; + + $this->assertSame('parent', $nativeType->name); + $this->assertNull($nativeType->dataClass); + $this->assertSame('parent', $phpDocType->iterableItemType?->getNamedTypes()[0]->name); + $this->assertNull($phpDocType->dataClass); + } + // Spatie's DataReturnTypeTest: return types are built by this factory. Spatie's buildFromNamedType() and // buildFromValue() only resolved collect() targets, which Hypervel resolves without type metadata. @@ -485,8 +496,15 @@ abstract class DataTypeFactoryItemData implements BaseData { } +trait DataTypeFactoryParentProperty +{ + public ?parent $unresolvedNativeParent = null; +} + class DataTypeFactoryFixture { + use DataTypeFactoryParentProperty; + public int $integer; /** @var int<0, max> */ @@ -524,6 +542,9 @@ class DataTypeFactoryFixture /** @var array */ public array $arrayKeys; + /** @var array */ + public array $unresolvedParent; + /** @var non-empty-array */ public array $nonEmptyArray; diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php index 740dd7d200..52e70ac377 100644 --- a/tests/Database/DatabaseEloquentFactoryTest.php +++ b/tests/Database/DatabaseEloquentFactoryTest.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Eloquent\Attributes\CollectedBy; use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder; use Hypervel\Database\Eloquent\Attributes\UseFactory; use Hypervel\Database\Eloquent\Builder; @@ -28,6 +29,7 @@ use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\Builder as SchemaBuilder; +use Hypervel\Events\Dispatcher; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Str; use Hypervel\Tests\Database\Fixtures\Models\Money\Price; @@ -248,6 +250,62 @@ public function testMakeManyCreatesUnpersistedModelInstances(): void $this->assertCount(0, User::all()); } + public function testCreateManyAndMakeManyReturnTheModelCollection(): void + { + $users = CollectedUserFactory::new()->createMany(2); + $this->assertInstanceOf(CollectedUserCollection::class, $users); + $this->assertCount(2, $users); + + $users = CollectedUserFactory::new()->createMany([]); + $this->assertInstanceOf(CollectedUserCollection::class, $users); + $this->assertCount(0, $users); + + $users = CollectedUserFactory::new()->makeMany(2); + $this->assertInstanceOf(CollectedUserCollection::class, $users); + $this->assertCount(2, $users); + + $users = CollectedUserFactory::new()->makeMany([]); + $this->assertInstanceOf(CollectedUserCollection::class, $users); + $this->assertCount(0, $users); + + $this->assertCount(2, CollectedUser::all()); + } + + public function testCreateManyQuietlyReturnsTheModelCollectionWithoutEvents(): void + { + Eloquent::setEventDispatcher(new Dispatcher); + + $created = 0; + CollectedUser::created(function () use (&$created) { + ++$created; + }); + + $users = CollectedUserFactory::new()->createManyQuietly(2); + + $this->assertInstanceOf(CollectedUserCollection::class, $users); + $this->assertCount(2, $users); + $this->assertSame(0, $created); + } + + public function testCreateManyAndMakeManyDoNotAutomaticallyEagerLoadRelationships(): void + { + Eloquent::automaticallyEagerLoadRelationships(); + + try { + foreach ([CollectedUserFactory::new()->createMany(2), CollectedUserFactory::new()->makeMany(2)] as $users) { + $this->assertInstanceOf(CollectedUserCollection::class, $users); + + foreach ($users as $user) { + $this->assertFalse($user->hasRelationAutoloadCallback()); + } + } + + $this->assertTrue(Eloquent::isAutomaticallyEagerLoadingRelationships()); + } finally { + Eloquent::automaticallyEagerLoadRelationships(false); + } + } + public function testBasicModelAttributesCanBeCreated(): void { $user = UserFactory::new()->raw(); @@ -1792,6 +1850,29 @@ public function definition(): array } } +#[CollectedBy(CollectedUserCollection::class)] +class CollectedUser extends Eloquent +{ + protected ?string $table = 'users'; +} + +/** @extends Collection */ +class CollectedUserCollection extends Collection +{ +} + +class CollectedUserFactory extends Factory +{ + protected ?string $model = CollectedUser::class; + + public function definition(): array + { + return [ + 'name' => 'Taylor Otwell', + ]; + } +} + class UserWithMutator extends User { public bool $timestamps = false; diff --git a/tests/Database/DatabaseEloquentJsonCastTest.php b/tests/Database/DatabaseEloquentJsonCastTest.php index 9e54921aba..e82202617c 100644 --- a/tests/Database/DatabaseEloquentJsonCastTest.php +++ b/tests/Database/DatabaseEloquentJsonCastTest.php @@ -51,19 +51,27 @@ public function testDefaultDecoderDistinguishesNullEmptyAndMalformedJson(): void $this->assertThrows(fn () => Json::decode('{invalid'), JsonException::class); } - public function testCustomDecoderReceivesTheStoredEmptyString(): void + public function testDefaultDecoderReturnsStoredNumbersUnchanged(): void { - $decoded = null; + $this->assertSame(5, Json::decode(5)); + $this->assertSame(1.5, Json::decode(1.5, false)); + $this->assertSame(0.30000000000000004, Json::decode(0.1 + 0.2)); + } + + public function testCustomDecoderReceivesTheStoredValueUnchanged(): void + { + $decoded = []; try { Json::decodeUsing(function (mixed $value) use (&$decoded): null { - $decoded = $value; + $decoded[] = $value; return null; }); $this->assertNull(Json::decode('')); - $this->assertSame('', $decoded); + $this->assertNull(Json::decode(5)); + $this->assertSame(['', 5], $decoded); } finally { Json::flushState(); } diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index a6b3d5e674..06c557e314 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -87,6 +87,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; +use ReflectionProperty; use RuntimeException; use stdClass; use Stringable as NativeStringable; @@ -3193,6 +3194,23 @@ public function testToArrayPassesRealValueToAppendedAccessor(): void $this->assertSame(100, $model->toArray()['price']); } + public function testToArraySerializesEnumCastAttributesThroughTheirGetAccessors(): void + { + $model = new EnumCastWithAccessorsStub; + $model->setRawAttributes(['legacy_status' => 'draft', 'status' => 'done']); + + $this->assertSame(['legacy_status' => 'Draft', 'status' => 'Done'], $model->toArray()); + } + + public function testToArrayAppendsCamelCaseAttributeAccessorBeforeItIsRead(): void + { + $model = new AppendsCamelCaseAttributeStub; + + $this->assertSame(['fullName' => 'Taylor Otwell'], $model->toArray()); + $this->assertSame('Taylor Otwell', $model->fullName); + $this->assertSame(['fullName' => 'Taylor Otwell'], $model->toArray()); + } + public function testMergeAppendsMergesAppends(): void { $model = new AppendsStub; @@ -4153,6 +4171,32 @@ public function testCastCacheIsClearedDuringSleep(): void $this->assertSame([], $invoker->castMetadataCache, 'cache should be cleared by __sleep'); } + public function testSerializationKeepsSubclassStateWithoutRunningPropertyHooks(): void + { + $model = new ModelPrivateStateStub; + $model->setRawAttributes(['name' => 'taylor']); + $model->changeState('child-changed'); + $model->changeParentState('parent-changed'); + $model->hookedLabel = 'label-changed'; + $model->changeHookedState('hooked-changed'); + + $unserialized = unserialize(serialize($model)); + + $this->assertSame('taylor', $unserialized->name); + $this->assertSame('child-changed', $unserialized->state()); + $this->assertSame('parent-changed', $unserialized->parentState()); + $this->assertSame('label-changed', (new ReflectionProperty($unserialized, 'hookedLabel'))->getRawValue($unserialized)); + $this->assertSame('hooked-changed', (new ReflectionProperty($unserialized, 'hookedState'))->getRawValue($unserialized)); + } + + public function testSleepOverridesCanExcludeProtectedPropertiesByName(): void + { + $model = new ModelFilteredSleepStub; + $model->changeTransientToken('changed'); + + $this->assertSame('default', unserialize(serialize($model))->transientToken()); + } + public function testIsClassCastableThrowsRepeatedlyForInvalidCast(): void { $invoker = new ClassInvoker(new CastCacheInvalidClassStub); @@ -4168,6 +4212,29 @@ public function testIsClassCastableThrowsRepeatedlyForInvalidCast(): void $invoker->isClassCastable('foo'); } + public function testPrimitiveCastsInAnotherCaseReadAndWrite(): void + { + Crypt::swap(new ConcreteEncrypter(str_repeat('a', 32), 'AES-256-CBC')); + + $model = new class extends Model { + protected array $casts = [ + 'flag' => 'BOOLEAN', + 'secrets' => 'ENCRYPTED:ARRAY', + ]; + }; + + $model->flag = false; + $model->secrets = ['key' => 'value']; + + $this->assertFalse($model->flag); + $this->assertSame(['key' => 'value'], json_decode(Crypt::decrypt($model->getAttributes()['secrets'], false), true)); + $this->assertSame(['key' => 'value'], $model->secrets); + + $model->setRawAttributes(['flag' => 1]); + + $this->assertTrue($model->flag); + } + public function testCastCacheIsIsolatedPerInstance(): void { $a = new ModelStub; @@ -5568,7 +5635,7 @@ public function publicDecrementEach(array $columns, array $extra = []) /** * Expose quiet multi-column increments for testing. */ - public function publicIncrementEachQuietly(array $columns, array $extra = []): int|false + public function publicIncrementEachQuietly(array $columns, array $extra = []): int { return $this->incrementEachQuietly($columns, $extra); } @@ -5576,7 +5643,7 @@ public function publicIncrementEachQuietly(array $columns, array $extra = []): i /** * Expose quiet multi-column decrements for testing. */ - public function publicDecrementEachQuietly(array $columns, array $extra = []): int|false + public function publicDecrementEachQuietly(array $columns, array $extra = []): int { return $this->decrementEachQuietly($columns, $extra); } @@ -5884,6 +5951,34 @@ protected function price(): Attribute } } +class EnumCastWithAccessorsStub extends Model +{ + protected array $casts = [ + 'legacy_status' => StringStatus::class, + 'status' => StringStatus::class, + ]; + + public function getLegacyStatusAttribute(string $value): string + { + return StringStatus::from($value)->name; + } + + protected function status(): Attribute + { + return Attribute::get(fn (string $value): string => StringStatus::from($value)->name); + } +} + +class AppendsCamelCaseAttributeStub extends Model +{ + protected array $appends = ['fullName']; + + protected function fullName(): Attribute + { + return Attribute::get(fn (): string => 'Taylor Otwell'); + } +} + class GetMutatorsStub extends Model { public static function resetMutatorCache() @@ -6647,3 +6742,96 @@ enum IntegerBackedConnectionName: int { case Zero = 0; } + +abstract class ModelPrivateStateParentStub extends Model +{ + private string $state = 'parent'; + + /** + * Change the parent's private state. + */ + public function changeParentState(string $state): void + { + $this->state = $state; + } + + /** + * Get the parent's private state. + */ + public function parentState(): string + { + return $this->state; + } +} + +class ModelPrivateStateStub extends ModelPrivateStateParentStub +{ + public string $hookedLabel = 'label' { + get => throw new LogicException('Serialization ran a get hook.'); + set => $value; + } + + private string $state = 'child'; + + private string $hookedState = 'hooked' { + get => throw new LogicException('Serialization ran a get hook.'); + set => $value; + } + + private string $virtual { + get => 'virtual'; + } + + /** + * Change the private state. + */ + public function changeState(string $state): void + { + $this->state = $state; + } + + /** + * Change the private hooked state. + */ + public function changeHookedState(string $state): void + { + $this->hookedState = $state; + } + + /** + * Get the private state. + */ + public function state(): string + { + return $this->state; + } +} + +class ModelFilteredSleepStub extends Model +{ + protected string $transientToken = 'default'; + + /** + * Change the transient token. + */ + public function changeTransientToken(string $token): void + { + $this->transientToken = $token; + } + + /** + * Get the transient token. + */ + public function transientToken(): string + { + return $this->transientToken; + } + + /** + * Leave the transient token out of serialization. + */ + public function __sleep(): array + { + return array_values(array_diff(parent::__sleep(), ['transientToken'])); + } +} diff --git a/tests/Database/DatabaseSchemaDumpCommandTest.php b/tests/Database/DatabaseSchemaDumpCommandTest.php index b7da12f91a..c7b28e0085 100644 --- a/tests/Database/DatabaseSchemaDumpCommandTest.php +++ b/tests/Database/DatabaseSchemaDumpCommandTest.php @@ -4,8 +4,13 @@ namespace Hypervel\Tests\Database; +use Hypervel\Database\Connection; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Console\DumpCommand; +use Hypervel\Database\Schema\SchemaState; use Hypervel\Testbench\TestCase; +use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; class DatabaseSchemaDumpCommandTest extends TestCase { @@ -26,4 +31,48 @@ public function testDumpCommandExitsWhenProhibited(): void $this->assertFileDoesNotExist($path); $this->assertDirectoryExists(database_path('migrations')); } + + /** + * @param array $options + */ + #[DataProvider('connectionSelectionProvider')] + public function testDumpUsesAndIsNamedAfterTheMigrationConnection(array $options): void + { + $schemaPath = database_path('schema'); + + if (! is_dir($schemaPath)) { + $this->beforeApplicationDestroyed(fn () => $this->app->make('files')->deleteDirectory($schemaPath)); + } + + config([ + 'database.default' => 'pgsql-pooled', + 'database.connections.pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'pgsql'], + 'database.connections.pgsql' => ['driver' => 'pgsql'], + ]); + + $connection = m::mock(Connection::class); + $state = m::mock(SchemaState::class); + $connection->allows('getName')->andReturn('pgsql'); + $connection->allows('getSchemaState')->andReturn($state); + $state->allows('withMigrationTable')->andReturnSelf(); + $state->allows('handleOutputUsing')->andReturnSelf(); + $state->expects('dump')->with($connection, database_path('schema/pgsql-schema.sql')); + + $connections = m::mock(ConnectionResolverInterface::class); + $connections->expects('connection')->with('pgsql')->andReturn($connection); + $this->app->instance(ConnectionResolverInterface::class, $connections); + + $this->artisan('schema:dump', $options)->assertSuccessful(); + } + + /** + * Provide the default and explicit selection of a connection that routes its migrations elsewhere. + * + * @return iterable}> + */ + public static function connectionSelectionProvider(): iterable + { + yield 'default connection' => [[]]; + yield 'explicit connection' => [['--database' => 'pgsql-pooled']]; + } } diff --git a/tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php b/tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php deleted file mode 100644 index 2b464987c8..0000000000 --- a/tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php +++ /dev/null @@ -1,28 +0,0 @@ -createMock(ReflectionProvider::class); - $reflectionProvider->expects($this->never())->method('getClass'); - - new ForwardedBuilderMethodExtension( - $reflectionProvider, - new ModelScopeMethodResolver, - new BuilderMacroResolver($reflectionProvider, [SoftDeletes::class => SoftDeletingMacros::class]), - ); - } -} diff --git a/tests/Doctor/Feature/Diagnostics/CacheStoreIsReachableTest.php b/tests/Doctor/Feature/Diagnostics/CacheStoreIsReachableTest.php index af5fec3022..a78724d34a 100644 --- a/tests/Doctor/Feature/Diagnostics/CacheStoreIsReachableTest.php +++ b/tests/Doctor/Feature/Diagnostics/CacheStoreIsReachableTest.php @@ -359,10 +359,14 @@ public static function probeFailures(): array } #[DataProvider('probeExceptions')] - public function testCleanupPreservesPrimaryFailuresAndCancellation(bool $primaryCanceled, bool $cleanupCanceled): void + public function testCleanupPreservesPrimaryFailuresAndCancellation(bool $primaryCanceled, bool $cleanupCanceled, bool $cleanupCarried = false): void { $primary = $primaryCanceled ? new CanceledException('primary canceled') : new RuntimeException('primary failed'); - $cleanup = $cleanupCanceled ? new CanceledException('cleanup canceled') : new RuntimeException('cleanup failed'); + $cleanup = match (true) { + $cleanupCarried => new RuntimeException('cleanup failed', previous: new CanceledException('cleanup canceled')), + $cleanupCanceled => new CanceledException('cleanup canceled'), + default => new RuntimeException('cleanup failed'), + }; $store = m::mock(Store::class); $store->expects('put')->andThrow($primary); $store->expects('forget')->andThrow($cleanup); @@ -391,7 +395,7 @@ public function testCleanupPreservesPrimaryFailuresAndCancellation(bool $primary */ public static function probeExceptions(): array { - return [[false, false], [true, false], [false, true], [true, true]]; + return [[false, false], [true, false], [false, true], [true, true], [false, true, true]]; } public function testNullStoreIsSkippedAndSessionStoreCanRoundTrip(): void diff --git a/tests/Doctor/Feature/Support/DoctorTest.php b/tests/Doctor/Feature/Support/DoctorTest.php index 3f1de97a83..cff8b791b6 100644 --- a/tests/Doctor/Feature/Support/DoctorTest.php +++ b/tests/Doctor/Feature/Support/DoctorTest.php @@ -34,9 +34,11 @@ use InvalidArgumentException; use LogicException; use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Command\Command; +use Throwable; class DoctorTest extends TestCase { @@ -601,14 +603,16 @@ public function testDiagnosticInstanceStateIsFreshForPostFixAndLaterRuns(): void } #[DataProvider('canceledOperations')] - public function testCancellationPropagatesUnchanged(string $operation): void + public function testCancellationPropagatesUnchanged(string $operation, bool $carried): void { - $cancellation = new CanceledException('Doctor operation canceled'); + $cancellation = $carried + ? new RuntimeException('Doctor cleanup failed', previous: new CanceledException('Doctor operation canceled')) + : new CanceledException('Doctor operation canceled'); $diagnostic = new class($cancellation) extends Diagnostic implements Fixable { /** * Capture the cancellation raised by diagnostic work. */ - public function __construct(private CanceledException $cancellation) + public function __construct(private Throwable $cancellation) { parent::__construct(); } @@ -636,18 +640,26 @@ public function fix(DiagnosticResult $result, ?string $option = null): FixResult 'check' => $doctor->check($diagnostic), 'fix' => $doctor->fix(new DiagnosticOutcome($diagnostic, DiagnosticResult::fail('Repair required')->fixable())), }; - $this->fail('The operation must propagate cancellation.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); + + return; } + + $this->fail('The operation must propagate cancellation.'); } /** - * Provide the runner's diagnostic entry points. + * Provide the runner's diagnostic entry points, with direct and carried cancellation. */ public static function canceledOperations(): array { - return [['check'], ['fix']]; + return [ + 'check' => ['check', false], + 'carried check' => ['check', true], + 'fix' => ['fix', false], + 'carried fix' => ['fix', true], + ]; } } diff --git a/tests/FacadeDocumenter/ConditionalDedupeTest.php b/tests/FacadeDocumenter/ConditionalDedupeTest.php index ba5b003c2d..71ac9f9d0f 100644 --- a/tests/FacadeDocumenter/ConditionalDedupeTest.php +++ b/tests/FacadeDocumenter/ConditionalDedupeTest.php @@ -91,6 +91,60 @@ class Facade $this->assertContains('object', $members); } + public function testParameterConditionalWithIdenticalBranchesIsCollapsed(): void + { + $this->writeAppFile( + 'Conditional/IdenticalBranches/Proxy.php', + <<<'PHP' + writeAppFile( + 'Conditional/IdenticalBranches/Facade.php', + <<<'PHP' + runDocumenter(['App\Conditional\IdenticalBranches\Facade']); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput() . $process->getOutput()); + $this->assertStringContainsString( + '@method static mixed when(callable|null $callback = null)', + $this->appFileContents('App\Conditional\IdenticalBranches\Facade'), + ); + } + public function testNestedGenericUnionIsNotShreddedByDedupe(): void { $this->writeAppFile( diff --git a/tests/Fortify/VerifyEmailControllerTest.php b/tests/Fortify/VerifyEmailControllerTest.php index bb47ed7804..24a5351f07 100644 --- a/tests/Fortify/VerifyEmailControllerTest.php +++ b/tests/Fortify/VerifyEmailControllerTest.php @@ -32,6 +32,8 @@ public function testTheEmailCanBeVerifiedUsingFailedOnUnknownFields(): void protected function assertEmailCanBeVerified(): void { + Event::fake([Verified::class]); + $user = $this->createUnverifiedUser([ 'email' => 'taylor@laravel.com', ]); @@ -51,6 +53,7 @@ protected function assertEmailCanBeVerified(): void $response->assertRedirect('http://foo.com/bar'); $this->assertTrue($user->fresh()->hasVerifiedEmail()); + Event::assertDispatched(Verified::class, static fn (Verified $event): bool => $user->is($event->user)); } public function testRedirectedIfEmailIsAlreadyVerified(): void diff --git a/tests/Foundation/Auth/EmailVerificationRequestTest.php b/tests/Foundation/Auth/EmailVerificationRequestTest.php index 2db1e8ffde..e1d816f29f 100644 --- a/tests/Foundation/Auth/EmailVerificationRequestTest.php +++ b/tests/Foundation/Auth/EmailVerificationRequestTest.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Auth\MustVerifyEmail; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Foundation\Auth\EmailVerificationRequest; +use Hypervel\Foundation\Auth\User; use Hypervel\Routing\Router; use Hypervel\Support\Facades\Event; use Hypervel\Testbench\TestCase; @@ -71,6 +72,26 @@ public function testFulfillMarksEmailAsVerifiedAndDispatchesEvent(): void Event::assertDispatched(Verified::class); } + public function testFulfillDispatchesEventForUserUsingOnlyTheVerificationTrait(): void + { + Event::fake([Verified::class]); + + $user = m::mock(User::class); + $user->shouldReceive('getKey')->andReturn(123); + $user->shouldReceive('getAuthIdentifier')->andReturn(123); + $user->shouldReceive('getEmailForVerification')->andReturn('user@example.com'); + $user->shouldReceive('hasVerifiedEmail')->once()->andReturn(false); + $user->shouldReceive('markEmailAsVerified')->once()->andReturn(true); + + $this->assertNotInstanceOf(MustVerifyEmail::class, $user); + + $this->actingAs($user) + ->get('/email/verify/123/' . sha1('user@example.com')) + ->assertOk(); + + Event::assertDispatched(Verified::class, static fn (Verified $event): bool => $event->user === $user); + } + public function testPassiveObserverDoesNotCauseVerifiedEventToDispatch(): void { $observedEvents = []; diff --git a/tests/Foundation/Bootstrap/LoadConfigurationTest.php b/tests/Foundation/Bootstrap/LoadConfigurationTest.php index 689d27d728..26a854595e 100644 --- a/tests/Foundation/Bootstrap/LoadConfigurationTest.php +++ b/tests/Foundation/Bootstrap/LoadConfigurationTest.php @@ -191,6 +191,33 @@ public function testAppConfigOverridesBaseConfigValues(): void $this->assertNotNull($config->get('auth')); } + public function testKeepsNestedFilesBelowFrameworkOnlyConfiguration(): void + { + $app = new Application(__DIR__ . '/../Fixtures'); + $app->useConfigPath(__DIR__ . '/../Fixtures/nested-config'); + + (new LoadConfiguration)->bootstrap($app); + + $config = $app->make('config'); + + $this->assertSame('stripe-key', $config->string('services.stripe.key')); + $this->assertIsArray($config->get('services.postmark')); + } + + public function testAppliesBaseEntriesAnOverriddenFileLoaderAddsOrChanges(): void + { + $app = new Application(__DIR__ . '/../Fixtures'); + $app->useConfigPath(__DIR__ . '/../Fixtures/nested-config'); + + (new ResidualBaseLoadConfiguration)->bootstrap($app); + + $config = $app->make('config'); + + $this->assertSame(['enabled' => true], $config->get('additional')); + $this->assertSame('override', $config->string('session.driver')); + $this->assertSame('stripe-key', $config->string('services.stripe.key')); + } + public function testFailedReloadRestoresThePreviousRepositoryAndException(): void { $app = new Application; @@ -213,6 +240,19 @@ public function testFailedReloadRestoresThePreviousRepositoryAndException(): voi } } +class ResidualBaseLoadConfiguration extends LoadConfiguration +{ + protected function loadConfigurationFile(RepositoryContract $repository, string $name, string $path, array $base): array + { + $base = parent::loadConfigurationFile($repository, $name, $path, $base); + + $base['additional'] = ['enabled' => true]; + $base['session']['driver'] = 'override'; + + return $base; + } +} + class FailingLoadConfiguration extends LoadConfiguration { public function __construct(protected RuntimeException $exception) diff --git a/tests/Foundation/Console/SyncSkeletonConfigTest.php b/tests/Foundation/Console/SyncSkeletonConfigTest.php index 484f03e9b7..804c7ccaa6 100644 --- a/tests/Foundation/Console/SyncSkeletonConfigTest.php +++ b/tests/Foundation/Console/SyncSkeletonConfigTest.php @@ -71,7 +71,7 @@ protected function setUp(): void // Starter kits can choose their default guard. 'guard' => env('AUTH_GUARD', 'web'), ], - 'model' => App\Models\User::class, // @phpstan-ignore class.notFound + 'model' => App\Models\User::class, // @phpstan-ignore class.notFound (the application's default user model) 'literal' => '// @phpstan-ignore class.notFound', ]; diff --git a/tests/Foundation/DeferredCallbacksTest.php b/tests/Foundation/DeferredCallbacksTest.php index 56e1dd331f..e74cbd8707 100644 --- a/tests/Foundation/DeferredCallbacksTest.php +++ b/tests/Foundation/DeferredCallbacksTest.php @@ -16,6 +16,7 @@ use Hypervel\Testbench\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use Swoole\Coroutine\CanceledException; class DeferredCallbacksTest extends TestCase { @@ -121,6 +122,22 @@ public function testJobLifecycleOwnsCallbacksRegisteredByNestedCommand(): void $this->assertCount(0, $this->app->make(DeferredCallbackCollection::class)); } + public function testCanceledJobAttemptSkipsEveryDeferredCallback(): void + { + defer(function (): void { + DeferredCallbacksTestState::record('deferred'); + }); + defer(function (): void { + DeferredCallbacksTestState::record('always'); + })->always(); + + $this->app->make(Dispatcher::class)->dispatch( + new JobAttempted('database', m::mock(Job::class), new CanceledException, canceled: true) + ); + + $this->assertSame([], DeferredCallbacksTestState::$calls); + } + public function testJobWithoutDeferredCallbacksDoesNotResolveTheCollection(): void { $job = m::mock(Job::class); diff --git a/tests/Foundation/Fixtures/nested-config/services/stripe.php b/tests/Foundation/Fixtures/nested-config/services/stripe.php new file mode 100644 index 0000000000..1f6b36babd --- /dev/null +++ b/tests/Foundation/Fixtures/nested-config/services/stripe.php @@ -0,0 +1,8 @@ + 'stripe-key', +]; diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index 626bc699cc..9842f3aff0 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -297,6 +297,8 @@ public function testEnvironment(): void $this->assertTrue($app->environment('f*')); $this->assertTrue($app->environment('foo', 'bar')); $this->assertTrue($app->environment(['foo', 'bar'])); + $this->assertTrue($app->environment(environments: 'foo')); + $this->assertTrue($app->environment(environments: ['qux', 'foo'])); $this->assertFalse($app->environment('qux')); $this->assertFalse($app->environment('q*')); diff --git a/tests/Foundation/PendingChainTest.php b/tests/Foundation/PendingChainTest.php index ff60d350df..e92ac5c7de 100644 --- a/tests/Foundation/PendingChainTest.php +++ b/tests/Foundation/PendingChainTest.php @@ -8,6 +8,9 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Foundation\Bus\PendingChain; +use Hypervel\Queue\Attributes\Connection; +use Hypervel\Queue\Attributes\Delay; +use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Tests\TestCase; use Mockery as m; @@ -85,6 +88,38 @@ public function testDispatchIgnoresEmptyChainIdentifiers(): void $this->assertNull($dispatched->chainConnection); $this->assertNull($dispatched->chainQueue); } + + public function testDispatchKeepsFirstJobAttributesOverChainDefaults(): void + { + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->once()->andReturnArg(0); + Container::getInstance()->instance(Dispatcher::class, $dispatcher); + + $dispatched = (new PendingChain(new PendingChainAttributedJob, [])) + ->onConnection('chain-connection') + ->onQueue('chain-queue') + ->delay(10) + ->dispatch(); + + $this->assertNull($dispatched->connection); + $this->assertNull($dispatched->queue); + $this->assertNull($dispatched->delay); + $this->assertSame('chain-connection', $dispatched->chainConnection); + $this->assertSame('chain-queue', $dispatched->chainQueue); + } + + public function testDispatchAppliesChainDelayOnlyWithoutAFirstJobDelay(): void + { + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->twice()->andReturnArg(0); + Container::getInstance()->instance(Dispatcher::class, $dispatcher); + + $delayed = (new PendingChain(new PendingChainTestJob, []))->delay(10)->dispatch(); + $immediate = (new PendingChain((new PendingChainTestJob)->withoutDelay(), []))->delay(10)->dispatch(); + + $this->assertSame(10, $delayed->delay); + $this->assertSame(0, $immediate->delay); + } } enum PendingChainIntegerIdentifier: int @@ -101,3 +136,11 @@ class PendingChainTestJob { use Queueable; } + +#[Connection('job-connection')] +#[QueueAttribute('job-queue')] +#[Delay(30)] +class PendingChainAttributedJob +{ + use Queueable; +} diff --git a/tests/Grpc/BaseClientTest.php b/tests/Grpc/BaseClientTest.php index aff6826a4c..3f09288fe6 100644 --- a/tests/Grpc/BaseClientTest.php +++ b/tests/Grpc/BaseClientTest.php @@ -301,16 +301,20 @@ static function ( #[TestWith([false])] #[TestWith([true])] - public function testCancellationWhileReleasingAnIdleConnectionDoesNotFinishTheOperation(bool $cleanupThrows): void + public function testCancellationWhileReleasingAnIdleConnectionFinishesTheOperationWithIt(bool $cleanupThrows): void { $engineClient = new ClientCallClient; $this->bindFactory(new ClientCallClientFactory($engineClient)); - $finished = false; + $finishedResult = null; Container::getInstance()->make(GrpcOperationRunner::class)->observe( new BaseClientGrpcOperationObserverStub( static fn (): null => null, - static function () use (&$finished): void { - $finished = true; + static function ( + GrpcOperation $operation, + mixed $token, + GrpcOperationResult $result, + ) use (&$finishedResult): void { + $finishedResult = $result; }, ), ); @@ -330,7 +334,10 @@ static function () use (&$finished): void { CoroutineContext::forget('__grpc.test.connection_lease'); } - $this->assertFalse($finished); + $this->assertInstanceOf(GrpcOperationResult::class, $finishedResult); + $this->assertNull($finishedResult->status); + $this->assertSame($failure, $finishedResult->exception); + $this->assertSame(1, $finishedResult->attemptCount); $this->assertSame([], $engineClient->sentRequests); } diff --git a/tests/Grpc/ConnectionTest.php b/tests/Grpc/ConnectionTest.php index 84257bf097..7c23315839 100644 --- a/tests/Grpc/ConnectionTest.php +++ b/tests/Grpc/ConnectionTest.php @@ -28,7 +28,9 @@ use Hypervel\Grpc\StatusCode; use Hypervel\Tests\TestCase; use LogicException; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; +use RuntimeException; use Swoole\Coroutine\CanceledException; use Throwable; @@ -1058,9 +1060,13 @@ public function testCloseIsTerminalIdempotentAndRejectsLaterWork(): void $connection->write($active, 'frame', false, Deadline::fromTimeout(null)); } - public function testNativeCloseCancellationPreservesTheCallerAfterTerminalCleanup(): void + #[TestWith([false])] + #[TestWith([true])] + public function testNativeCloseCancellationPreservesTheCallerAfterTerminalCleanup(bool $carried): void { - $cancellation = new CanceledException; + $cancellation = $carried + ? new RuntimeException('Close failed.', previous: new CanceledException) + : new CanceledException; $client = new ConnectionTestClient; $client->closeUsing(static function () use ($cancellation): void { throw $cancellation; @@ -1068,14 +1074,15 @@ public function testNativeCloseCancellationPreservesTheCallerAfterTerminalCleanu $connection = $this->connection(new ConnectionTestClientFactory($client)); $state = $this->state(); $this->start($connection, $this->request(), $state); + $caught = null; try { $connection->close(); - $this->fail('Expected cancellation to propagate.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + $this->assertSame($cancellation, $caught); $this->assertTrue($connection->isClosed()); $this->expectException(ConnectionException::class); diff --git a/tests/Grpc/GrpcOperationRunnerTest.php b/tests/Grpc/GrpcOperationRunnerTest.php index 2ff913173b..5b4953d3da 100644 --- a/tests/Grpc/GrpcOperationRunnerTest.php +++ b/tests/Grpc/GrpcOperationRunnerTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Grpc; use Closure; +use Hypervel\Container\Container; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Grpc\ClientGrpcOperation; use Hypervel\Grpc\Contracts\GrpcOperationObserver; use Hypervel\Grpc\GrpcOperation; @@ -16,6 +18,8 @@ use Hypervel\Grpc\Status; use Hypervel\Grpc\StatusCode; use Hypervel\Tests\TestCase; +use Mockery as m; +use Mockery\MockInterface; use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -117,6 +121,7 @@ function () use ($startingFailure): never { throw $startingFailure; }, )); + $this->bindReportingHandler()->shouldReceive('report')->once()->with($completionFailure); try { $runner->start($this->clientOperation()); @@ -130,7 +135,7 @@ function () use ($startingFailure): never { $this->assertSame(0, $receivedResult->attemptCount); } - public function testSuccessfulFinishAttemptsEveryObserverAndThrowsTheFirstFailureOnce(): void + public function testSuccessfulFinishAttemptsEveryObserverThrowsTheFirstFailureOnceAndReportsTheRest(): void { $runner = new GrpcOperationRunner; $firstFailure = new RuntimeException('first failed'); @@ -155,6 +160,7 @@ function () use (&$finished, $secondFailure): never { )); $handle = $runner->start($this->clientOperation()); $result = new GrpcOperationResult(new Status(StatusCode::Ok), null, 2); + $this->bindReportingHandler()->shouldReceive('report')->once()->with($secondFailure); try { $handle->finish($result); @@ -169,7 +175,7 @@ function () use (&$finished, $secondFailure): never { $this->assertSame(['first', 'second'], $finished); } - public function testRealFailureSuppressesOrdinaryObserverFailures(): void + public function testRealFailureReportsOrdinaryObserverFailuresInsteadOfThrowingThem(): void { foreach ([ new GrpcOperationResult(new Status(StatusCode::Unavailable), null, 1), @@ -177,15 +183,17 @@ public function testRealFailureSuppressesOrdinaryObserverFailures(): void ] as $result) { $runner = new GrpcOperationRunner; $finished = 0; + $observerFailure = new RuntimeException('observer failed'); $runner->observe($this->observer( fn (): null => null, - function () use (&$finished): never { + function () use (&$finished, $observerFailure): never { ++$finished; - throw new RuntimeException('observer failed'); + throw $observerFailure; }, )); + $this->bindReportingHandler()->shouldReceive('report')->once()->with($observerFailure); $runner->start($this->clientOperation())->finish($result); @@ -193,19 +201,39 @@ function () use (&$finished): never { } } + public function testObserverRethrowingTheOperationsFailureIsNotReportedAsItsOwn(): void + { + $runner = new GrpcOperationRunner; + $failure = new RuntimeException('transport failed'); + $runner->observe($this->observer( + fn (): null => null, + function (GrpcOperation $operation, mixed $token, GrpcOperationResult $result): never { + throw $result->exception; + }, + )); + $reported = []; + $this->bindReportingHandler()->shouldReceive('report')->andReturnUsing(function (Throwable $throwable) use (&$reported): void { + $reported[] = $throwable; + }); + + $runner->start($this->clientOperation())->finish(new GrpcOperationResult(null, $failure, 1)); + + $this->assertSame([], $reported); + } + #[TestWith([false])] #[TestWith([true])] - public function testStartingCancellationSkipsPreviouslyStartedCompletions(bool $cleanupThrows): void + public function testStartingCancellationFinishesPreviouslyStartedObservers(bool $cleanupThrows): void { $runner = new GrpcOperationRunner; $cancellation = new CanceledException; $failure = $cleanupThrows ? new RuntimeException('Observer cleanup failed.', previous: $cancellation) : $cancellation; - $finished = false; + $receivedResult = null; $runner->observe($this->observer( fn (): null => null, - function () use (&$finished): void { - $finished = true; + function (GrpcOperation $operation, mixed $token, GrpcOperationResult $result) use (&$receivedResult): void { + $receivedResult = $result; }, )); $runner->observe($this->observer( @@ -221,12 +249,12 @@ function () use ($failure): never { $this->assertSame($failure, $exception); } - $this->assertFalse($finished); + $this->assertSame($failure, $receivedResult?->exception); } #[TestWith([false])] #[TestWith([true])] - public function testCompletionCancellationStopsLaterObserversAndSupersedesARealFailure(bool $cleanupThrows): void + public function testCompletionCancellationNotifiesLaterObserversAndSupersedesARealFailure(bool $cleanupThrows): void { $runner = new GrpcOperationRunner; $cancellation = new CanceledException; @@ -259,7 +287,26 @@ function () use (&$laterFinished): void { } $this->assertTrue($handle->isFinished()); - $this->assertFalse($laterFinished); + $this->assertTrue($laterFinished); + } + + public function testObserverCancellationDoesNotReplaceTheOperationsOwnCancellation(): void + { + $runner = new GrpcOperationRunner; + $observerFailure = new RuntimeException('Observer cleanup failed.', previous: new CanceledException); + + $runner->observe($this->observer( + fn (): null => null, + function () use ($observerFailure): never { + throw $observerFailure; + }, + )); + $this->bindReportingHandler()->shouldReceive('report')->once()->with($observerFailure); + $handle = $runner->start($this->clientOperation()); + + $handle->finish(new GrpcOperationResult(null, new CanceledException, 1)); + + $this->assertTrue($handle->isFinished()); } private function clientOperation(): ClientGrpcOperation @@ -279,6 +326,19 @@ private function observer(Closure $starting, ?Closure $finished = null): GrpcOpe $finished ?? static fn (): null => null, ); } + + /** + * Bind a mocked exception handler for displaced failure reports. + */ + private function bindReportingHandler(): MockInterface + { + $container = new Container; + Container::setInstance($container); + $handler = m::mock(ExceptionHandler::class); + $container->instance(ExceptionHandler::class, $handler); + + return $handler; + } } class GrpcOperationObserverStub implements GrpcOperationObserver diff --git a/tests/Grpc/ServerTest.php b/tests/Grpc/ServerTest.php index 3d580fa77c..8967683406 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 PHPUnit\Framework\Attributes\TestWith; use Psr\Log\LogLevel; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -349,7 +350,7 @@ static function (GPBEmpty $request): iterable { $environment->contexts->get(); } - public function testCancellationSkipsObserverCompletionAndStillCleansTheCall(): void + public function testCancellationFinishesObservationWithoutAStatusAndStillCleansTheCall(): void { $environment = $this->environment(); $observer = new RecordingServerGrpcOperationObserver; @@ -366,7 +367,72 @@ public function testCancellationSkipsObserverCompletionAndStillCleansTheCall(): } $this->assertCount(1, $observer->started); - $this->assertSame([], $observer->finished); + $this->assertCount(1, $observer->finished); + $this->assertNull($observer->finished[0][2]->status); + $this->assertSame($cancellation, $observer->finished[0][2]->exception); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('No gRPC server call is active'); + + $environment->contexts->get(); + } + + #[TestWith([null])] + #[TestWith(['direct'])] + #[TestWith(['wrapped'])] + public function testCancellationAfterATerminalFailureKeepsTheFailureAsTheOutcome(?string $observerCancellation): void + { + $failure = new RuntimeException('route listener failed'); + $environment = $this->environment(); + $container = new Container; + Container::setInstance($container); + $container->instance(ExceptionHandler::class, $environment->exceptionHandler); + $environment->router->matched(static function () use ($failure): never { + throw $failure; + }); + $observer = new RecordingServerGrpcOperationObserver; + $environment->operations->observe($observer); + $observerFailure = match ($observerCancellation) { + 'direct' => new CanceledException, + 'wrapped' => new RuntimeException('observer cleanup failed', previous: new CanceledException), + default => null, + }; + + if ($observerFailure !== null) { + $environment->operations->observe(new class($observerFailure) implements GrpcOperationObserver { + public function __construct(private Throwable $failure) + { + } + + public function starting(GrpcOperation $operation): null + { + return null; + } + + public function finished(GrpcOperation $operation, mixed $token, GrpcOperationResult $result): void + { + throw $this->failure; + } + }); + } + + [$swooleResponse, $capture] = $this->response(); + $cancellation = new CanceledException; + $capture->endFailure = $cancellation; + + try { + $environment->server->onRequest($this->request(), $swooleResponse); + $this->fail('Expected the error response emission to be canceled.'); + } catch (Throwable $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertSame( + $observerCancellation === 'wrapped' ? [$failure, $observerFailure] : [$failure], + $environment->exceptionHandler->reported, + ); + $this->assertNull($observer->finished[0][2]->status); + $this->assertSame($failure, $observer->finished[0][2]->exception); $this->expectException(LogicException::class); $this->expectExceptionMessageIsOrContains('No gRPC server call is active'); diff --git a/tests/Grpc/UnaryCallTest.php b/tests/Grpc/UnaryCallTest.php index 9c68b7a5b2..22391c0c59 100644 --- a/tests/Grpc/UnaryCallTest.php +++ b/tests/Grpc/UnaryCallTest.php @@ -226,7 +226,10 @@ public function testCancelMapsARetryableCompletedAttemptAcrossEveryObserver(): v } } - public function testDroppingAnUnfinishedCallReleasesResourcesWithoutFinishingObservation(): void + #[TestWith([null])] + #[TestWith(['failure'], 'observer failure')] + #[TestWith(['cancellation'], 'observer cancellation')] + public function testDroppingAnUnfinishedCallFinishesObservationWithoutAResultAndReleasesResources(?string $observerFailure): void { $deadline = Deadline::fromTimeout(null); $state = $this->state($deadline); @@ -235,6 +238,11 @@ public function testDroppingAnUnfinishedCallReleasesResourcesWithoutFinishingObs ++$abandonments; }); $observer = new UnaryCallGrpcOperationObserverStub; + $observer->failure = match ($observerFailure) { + 'failure' => new RuntimeException('observer failed'), + 'cancellation' => new CanceledException, + default => null, + }; $operationHandle = new GrpcOperationHandle( new UnaryCallGrpcOperationStub, [[$observer, null]], @@ -245,8 +253,29 @@ public function testDroppingAnUnfinishedCallReleasesResourcesWithoutFinishingObs $this->assertSame(1, $abandonments); $this->assertTrue($state->isAbandoned()); - $this->assertSame([], $observer->results); - $this->assertFalse($operationHandle->isFinished()); + $this->assertCount(1, $observer->results); + $this->assertNull($observer->results[0]->status); + $this->assertNull($observer->results[0]->exception); + $this->assertTrue($operationHandle->isFinished()); + } + + public function testDroppingACompletedCallFinishesObservationWithItsTransportResult(): void + { + $deadline = Deadline::fromTimeout(null); + $state = $this->state($deadline); + $state->handle($this->trailersOnly(status: StatusCode::Unavailable)); + $observer = new UnaryCallGrpcOperationObserverStub; + $call = $this->call( + $state, + $deadline, + operationHandle: new GrpcOperationHandle(new UnaryCallGrpcOperationStub, [[$observer, null]]), + ); + + unset($call); + + $this->assertFalse($state->isAbandoned()); + $this->assertCount(1, $observer->results); + $this->assertSame(StatusCode::Unavailable, $observer->results[0]->status?->code()); } public function testWaitRejectsAResponseWithoutAMessage(): void @@ -1110,6 +1139,8 @@ class UnaryCallGrpcOperationObserverStub implements GrpcOperationObserver /** @var list */ public array $results = []; + public ?Throwable $failure = null; + public function starting(GrpcOperation $operation): null { return null; @@ -1121,5 +1152,9 @@ public function finished( GrpcOperationResult $result, ): void { $this->results[] = $result; + + if ($this->failure !== null) { + throw $this->failure; + } } } diff --git a/tests/Horizon/Unit/RedisQueueTest.php b/tests/Horizon/Unit/RedisQueueTest.php index 1ec4d2d1a8..cedf4c54b5 100644 --- a/tests/Horizon/Unit/RedisQueueTest.php +++ b/tests/Horizon/Unit/RedisQueueTest.php @@ -19,6 +19,7 @@ use Hypervel\Horizon\RedisQueue; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; +use Hypervel\Queue\Events\JobQueueingCanceled; use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Queue\LuaScripts; use Hypervel\Redis\RedisProxy; @@ -116,13 +117,19 @@ public function testPostPushFailurePreservesAcceptanceAndNotifiesEveryMember(str $this->assertTrue($cache->lock(UniqueLock::getKey($job))->isLocked()); } - public function testPostPushCancellationStopsNotificationsWithoutReplacingCancellation(): void + public function testPostPushCancellationStillPublishesTheQueuedOutcome(): void { $stored = []; [$queue, $events] = $this->publishingQueue($stored); $cancellation = new CanceledException; $events->listen(JobPushed::class, static fn () => throw $cancellation); - $events->listen([JobQueued::class, JobQueueingFailed::class], static fn () => throw new RuntimeException('Must not run after cancellation.')); + $queued = $unqueued = []; + $events->listen(JobQueued::class, static function (JobQueued $event) use (&$queued): void { + $queued[] = $event->job; + }); + $events->listen([JobQueueingFailed::class, JobQueueingCanceled::class], static function (object $event) use (&$unqueued): void { + $unqueued[] = $event; + }); try { $queue->push('job'); @@ -132,6 +139,8 @@ public function testPostPushCancellationStopsNotificationsWithoutReplacingCancel } $this->assertCount(1, $stored); + $this->assertSame(['job'], $queued); + $this->assertSame([], $unqueued); } public function testReadyNowReadsTheClusterSafeQueueKey(): void diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php index aadffbcaf8..3b99c12634 100644 --- a/tests/Http/HttpRequestTest.php +++ b/tests/Http/HttpRequestTest.php @@ -10,6 +10,7 @@ use Hypervel\Routing\Route; use Hypervel\Session\NullSessionHandler; use Hypervel\Session\Store; +use Hypervel\Support\Stringable; use Hypervel\Tests\Database\Fixtures\Models\Money\Price; use Hypervel\Tests\Support\Concerns\TestsInteractsWithDataContract; use Hypervel\Tests\TestCase; @@ -785,6 +786,29 @@ public function testCookieMethod(): void $this->assertSame('Taylor', $all['name']); } + public function testInputSourceGettersReturnAnyDefaultAndStoredValue(): void + { + $request = Request::create('/', 'GET'); + $this->assertSame(15, $request->query('per_page', 15)); + $this->assertFalse($request->cookie('consent', false)); + $this->assertTrue($request->server('MISSING', true)); + + $request->merge(['active' => true]); + $this->assertTrue($request->query('active')); + + $name = new Stringable('Taylor'); + $request->query->set('name', $name); + $this->assertSame($name, $request->query('name')); + + $request->server->set('HTTPS', true); + $this->assertTrue($request->server('HTTPS')); + + $request = Request::createFromBase( + SymfonyRequest::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], '{"count":5}') + ); + $this->assertSame(5, $request->post('count')); + } + public function testHasCookieMethod(): void { $request = Request::create('/', 'GET', [], ['foo' => 'bar']); @@ -805,6 +829,8 @@ public function testFileMethod(): void ]; $request = Request::create('/', 'GET', [], [], $files); $this->assertInstanceOf(SymfonyUploadedFile::class, $request->file('foo')); + $this->assertSame('none', $request->file('bar', 'none')); + $this->assertSame('lazy', $request->file('bar', fn () => 'lazy')); } public function testImageMethod(): void @@ -976,6 +1002,10 @@ public function testBearerTokenMethod(): void $request = Request::create('/', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'foo,bar']); $this->assertNull($request->bearerToken()); + + $request = Request::create('/'); + $request->headers->set('Authorization', null); + $this->assertNull($request->bearerToken()); } public function testJSONMethod(): void diff --git a/tests/HttpServer/RequestBridgeTest.php b/tests/HttpServer/RequestBridgeTest.php index 64f89064ce..27865bae99 100644 --- a/tests/HttpServer/RequestBridgeTest.php +++ b/tests/HttpServer/RequestBridgeTest.php @@ -174,7 +174,7 @@ public function testCreateFromSwooleWithCookies(): void public function testCreateFromSwooleWithRawJsonBody(): void { - $body = '{"name":"Taylor","role":"admin"}'; + $body = '{"name":"Taylor","age":38,"admin":true,"profile":{"city":"Paris"}}'; $swooleRequest = $this->createSwooleRequest( server: ['request_method' => 'post', 'request_uri' => '/api/users'], @@ -185,6 +185,28 @@ public function testCreateFromSwooleWithRawJsonBody(): void $request = RequestBridge::createFromSwoole($swooleRequest); $this->assertSame($body, $request->getContent()); + $this->assertSame(38, $request->post('age')); + $this->assertTrue($request->input('admin')); + $this->assertSame(['city' => 'Paris'], $request->request->all('profile')); + + // The request bag and the JSON payload are the same bag. + $request->merge(['team' => 'core']); + $this->assertSame('core', $request->request->get('team')); + $this->assertSame('core', $request->json('team')); + } + + public function testCreateFromSwooleWithMalformedJsonBody(): void + { + $swooleRequest = $this->createSwooleRequest( + server: ['request_method' => 'post', 'request_uri' => '/api/users'], + header: ['host' => 'example.com', 'content-type' => 'application/json'], + rawContent: '{"name":', + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + + $this->assertSame('{"name":', $request->getContent()); + $this->assertSame([], $request->post()); } public function testCreateFromSwooleWithEmptyRawContent(): void diff --git a/tests/HttpServer/ServerTest.php b/tests/HttpServer/ServerTest.php index 254bdbb47b..1d975f14ff 100644 --- a/tests/HttpServer/ServerTest.php +++ b/tests/HttpServer/ServerTest.php @@ -4,8 +4,10 @@ namespace Hypervel\Tests\HttpServer; +use Hypervel\Container\Container as BaseContainer; use Hypervel\Context\RequestContext; use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher as EventDispatcherContract; use Hypervel\Contracts\Http\Kernel as KernelContract; use Hypervel\Coordinator\Constants; @@ -15,6 +17,7 @@ use Hypervel\Events\Dispatcher; use Hypervel\Filesystem\Filesystem; use Hypervel\Http\Request; +use Hypervel\HttpServer\Events\RequestCanceled; use Hypervel\HttpServer\Events\RequestHandled; use Hypervel\HttpServer\Events\RequestReceived; use Hypervel\HttpServer\Events\RequestTerminated; @@ -24,6 +27,7 @@ use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use Mockery as m; +use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; @@ -294,6 +298,11 @@ public function testOnRequestPreservesOperationFailureAcrossExhaustiveFinalizati $sendFailure = new RuntimeException('send failed'); $terminateFailure = new RuntimeException('terminate failed'); + $handler = $this->bindReportingHandler(); + $handler->expects('report')->with($handledFailure)->ordered(); + $handler->expects('report')->with($sendFailure)->ordered(); + $handler->expects('report')->with($terminateFailure)->ordered(); + $kernel = m::mock(KernelContract::class); $kernel->shouldNotReceive('handle'); $kernel->shouldReceive('terminate') @@ -343,6 +352,9 @@ public function testOnRequestFinalizationIsExhaustiveAndPreservesTheFirstFailure $handledFailure = new RuntimeException('handled failed'); $sendFailure = new RuntimeException('send failed'); $terminateFailure = new RuntimeException('terminate failed'); + $handler = $this->bindReportingHandler(); + $handler->expects('report')->with($sendFailure)->ordered(); + $handler->expects('report')->with($terminateFailure)->ordered(); $kernel = m::mock(KernelContract::class); $kernel->shouldReceive('handle')->once()->andReturn(new Response('OK')); $kernel->shouldReceive('terminate')->once()->andThrow($terminateFailure); @@ -383,6 +395,7 @@ public function testRequestTerminatedObservesSendFailureBeforeLaterTerminationFa CoordinatorManager::until(Constants::WORKER_START)->resume(); $sendFailure = new RuntimeException('send failed'); $terminateFailure = new RuntimeException('terminate failed'); + $this->bindReportingHandler()->expects('report')->with($terminateFailure); $kernel = m::mock(KernelContract::class); $kernel->shouldReceive('handle')->once()->andReturn(new Response('OK')); @@ -589,7 +602,7 @@ public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellatio $dispatchedEvents = []; $events = new Dispatcher; - foreach ([RequestHandled::class, ResponseSent::class, RequestTerminated::class] as $eventClass) { + foreach ([RequestHandled::class, ResponseSent::class, RequestTerminated::class, RequestCanceled::class] as $eventClass) { $events->listen($eventClass, function (object $event) use (&$dispatchedEvents): void { $dispatchedEvents[$event::class] = $event; }); @@ -626,7 +639,10 @@ public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellatio try { $this->assertTrue($completed->pop(1)); $this->assertSame($cancellation, $caughtCancellation); - $this->assertSame([], $dispatchedEvents); + $this->assertSame([RequestCanceled::class], array_keys($dispatchedEvents)); + $this->assertSame($cancellation, $dispatchedEvents[RequestCanceled::class]->cancellation); + $this->assertNull($dispatchedEvents[RequestCanceled::class]->response); + $this->assertNull($dispatchedEvents[RequestCanceled::class]->exception); $this->assertFalse(RequestContext::has()); } finally { if (EngineCoroutine::exists($requestCoroutine->getId())) { @@ -680,6 +696,11 @@ public function testOnRequestStopsAtCancellationDuringFinalization( $events->listen(RequestTerminated::class, function () use (&$order): void { $order[] = 'terminated'; }); + $canceled = null; + $events->listen(RequestCanceled::class, function (RequestCanceled $event) use (&$order, &$canceled): void { + $order[] = 'canceled'; + $canceled = $event; + }); $container = m::mock(Container::class); $container->shouldReceive('bound')->with('events')->andReturn(true); @@ -717,7 +738,10 @@ public function testOnRequestStopsAtCancellationDuringFinalization( } $this->assertSame($cancellation, $caught); - $this->assertSame($expectedOrder, $order); + $this->assertSame([...$expectedOrder, 'canceled'], $order); + $this->assertSame($cancellation, $canceled->cancellation); + $this->assertSame(in_array($stage, ['handled', 'send'], true), $canceled->response === null); + $this->assertNull($canceled->exception); } public static function lifecycleCancellationStages(): array @@ -734,6 +758,54 @@ public static function lifecycleCancellationStages(): array ]; } + public function testCancellationReportsTheHeldFailureAndCanceledListenerFailures(): void + { + CoordinatorManager::until(Constants::WORKER_START)->resume(); + $heldFailure = new RuntimeException('Handled listener failed.'); + $listenerFailure = new RuntimeException('Canceled listener failed.'); + $cancellation = new CanceledException; + $handler = $this->bindReportingHandler(); + $handler->expects('report')->with($listenerFailure)->ordered(); + $handler->expects('report')->with($heldFailure)->ordered(); + + $kernel = m::mock(KernelContract::class); + $kernel->shouldReceive('handle')->once()->andReturn(new Response('OK')); + $kernel->shouldNotReceive('terminate'); + + $events = new Dispatcher; + $events->listen(RequestHandled::class, static fn () => throw $heldFailure); + $events->listen(ResponseSent::class, static fn () => throw $cancellation); + $canceled = null; + $events->listen(RequestCanceled::class, static function (RequestCanceled $event) use (&$canceled, $listenerFailure): never { + $canceled = $event; + + throw $listenerFailure; + }); + + $container = m::mock(Container::class); + $container->shouldReceive('bound')->with('events')->andReturn(true); + $container->shouldReceive('make')->with('events')->andReturn($events); + + $server = new Server($container); + $this->setKernel($server, $kernel); + $this->setServerName($server, 'http'); + + $swooleResponse = m::mock(SwooleResponse::class); + $swooleResponse->shouldReceive('status')->once()->with(200)->andReturnTrue(); + $swooleResponse->shouldReceive('header')->withAnyArgs()->andReturnTrue(); + $swooleResponse->shouldReceive('end')->once()->with('OK')->andReturnTrue(); + + try { + $server->onRequest($this->createSwooleRequest(), $swooleResponse); + $this->fail('Expected the cancellation to escape.'); + } catch (CanceledException $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertSame($heldFailure, $canceled->exception); + $this->assertNotNull($canceled->response); + } + public function testOnRequestSkipsLifecycleEventDispatchWhenNoListenersAreRegistered(): void { CoordinatorManager::until(Constants::WORKER_START)->resume(); @@ -865,6 +937,19 @@ private function createSwooleRequest( return $swooleRequest; } + /** + * Bind a mocked exception handler for displaced failure reports. + */ + private function bindReportingHandler(): MockInterface + { + $container = new BaseContainer; + BaseContainer::setInstance($container); + $handler = m::mock(ExceptionHandler::class); + $container->instance(ExceptionHandler::class, $handler); + + return $handler; + } + /** * Set the kernel on the server via reflection. */ diff --git a/tests/Image/CoroutineSafetyTest.php b/tests/Image/CoroutineSafetyTest.php index 6eed8d2f5a..60d29657c6 100644 --- a/tests/Image/CoroutineSafetyTest.php +++ b/tests/Image/CoroutineSafetyTest.php @@ -146,8 +146,7 @@ public function testOwnerCancellationReachesOnlyTheOwnerAndOthersGetTheCachedFai $results = [$first[0] => $first[1], $second[0] => $second[1]]; if ($cleanupThrows) { - $this->assertInstanceOf(ImageException::class, $results['owner']); - $this->assertSame($cleanupException, $results['owner']->getPrevious()); + $this->assertSame($cleanupException, $results['owner']); $this->assertInstanceOf(CanceledException::class, $cleanupException->getPrevious()); } else { $this->assertInstanceOf(CanceledException::class, $results['owner']); @@ -156,7 +155,7 @@ public function testOwnerCancellationReachesOnlyTheOwnerAndOthersGetTheCachedFai $this->assertTrue(Coroutine::causedByCancellation($results['owner'])); $this->assertInstanceOf(ImageException::class, $results['waiter']); $this->assertInstanceOf(ForeignCancellationException::class, $results['waiter']->getPrevious()); - $this->assertSame($cleanupThrows ? $cleanupException : $results['owner'], $results['waiter']->getPrevious()->getPrevious()); + $this->assertSame($results['owner'], $results['waiter']->getPrevious()->getPrevious()); $this->assertFalse(Coroutine::causedByCancellation($results['waiter'])); } finally { $release->push(true, 0.001); diff --git a/tests/Image/ImageManagerTest.php b/tests/Image/ImageManagerTest.php index 5297a6abf9..6ecb55c95d 100644 --- a/tests/Image/ImageManagerTest.php +++ b/tests/Image/ImageManagerTest.php @@ -82,6 +82,15 @@ public function testThrowsForUnsupportedDriver(): void $manager->driver('nonexistent'); } + public function testThrowsForADriverNameWithoutACreatorMethod(): void + { + $manager = new ImageManager($this->makeApp([])); + + $this->expectExceptionObject(new InvalidArgumentException('Image driver [-] is not supported.')); + + $manager->driver('-'); + } + public function testCustomDriverExceptionPropagatesUnchanged(): void { $creatorException = new InvalidArgumentException('Unable to create the custom image driver.'); diff --git a/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php b/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php index 4793c20166..51d2dc48c6 100644 --- a/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php +++ b/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php @@ -13,15 +13,12 @@ use RuntimeException; use function Hypervel\Coroutine\go; +use function Hypervel\Coroutine\parallel; /** - * Tests coroutine safety of Model state methods. - * - * These tests verify that withoutEvents(), withoutBroadcasting(), - * withoutTouching(), and withoutTimestamps() use Context - * (per-coroutine storage) rather than static properties - * (process-global), ensuring concurrent requests don't interfere - * with each other. + * Tests that Model's scoped state methods use Context (per-coroutine storage) + * rather than static properties (process-global), so concurrent requests + * don't interfere with each other. */ class ModelCoroutineSafetyTest extends DatabaseTestCase { @@ -408,6 +405,63 @@ public function testWithoutTimestampsIsCoroutineIsolated(): void $this->assertFalse($results[2], 'Coroutine 2 should NOT be ignoring timestamps (isolated context)'); } + public function testWithoutAutomaticallyEagerLoadingRelationshipsRestoresNestedStateAfterException(): void + { + Model::automaticallyEagerLoadRelationships(); + $expectedException = new RuntimeException('Test exception'); + $caughtException = null; + + try { + $result = Model::withoutAutomaticallyEagerLoadingRelationships(function () use ($expectedException, &$caughtException) { + try { + Model::withoutAutomaticallyEagerLoadingRelationships(function () use ($expectedException): never { + $this->assertFalse(Model::isAutomaticallyEagerLoadingRelationships()); + + throw $expectedException; + }); + } catch (RuntimeException $exception) { + $caughtException = $exception; + } + + $this->assertFalse(Model::isAutomaticallyEagerLoadingRelationships()); + + return 'result'; + }); + + $this->assertSame('result', $result); + $this->assertSame($expectedException, $caughtException); + $this->assertTrue(Model::isAutomaticallyEagerLoadingRelationships()); + } finally { + Model::automaticallyEagerLoadRelationships(false); + } + } + + public function testWithoutAutomaticallyEagerLoadingRelationshipsIsCoroutineIsolated(): void + { + Model::automaticallyEagerLoadRelationships(); + + try { + [$suppressed, $isolated] = parallel([ + fn () => Model::withoutAutomaticallyEagerLoadingRelationships(function () { + usleep(50000); + + return Model::isAutomaticallyEagerLoadingRelationships(); + }), + function () { + usleep(10000); + + return Model::isAutomaticallyEagerLoadingRelationships(); + }, + ]); + + $this->assertFalse($suppressed, 'Coroutine 1 should not automatically eager load relationships'); + $this->assertTrue($isolated, 'Coroutine 2 should keep the boot setting (isolated context)'); + $this->assertTrue(Model::isAutomaticallyEagerLoadingRelationships()); + } finally { + Model::automaticallyEagerLoadRelationships(false); + } + } + public function testWithoutRecursionIsCoroutineIsolated(): void { $model = new RecursionTestModel; diff --git a/tests/Integration/Database/EloquentModelJsonCastingTest.php b/tests/Integration/Database/EloquentModelJsonCastingTest.php index 4acbde352f..cdcb67331e 100644 --- a/tests/Integration/Database/EloquentModelJsonCastingTest.php +++ b/tests/Integration/Database/EloquentModelJsonCastingTest.php @@ -10,7 +10,9 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Collection; +use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Tests\Integration\Database\DatabaseTestCase; use stdClass; @@ -76,6 +78,33 @@ public function testCollectionsAreCastable(): void $this->assertSame('value1', $user->collection_as_json_field->get('key1')); } + #[RequiresDatabase('sqlite')] + public function testNumbersReadFromNativeSqliteJsonColumnsAreCastable(): void + { + // Blueprint creates text columns unless use_native_json is set, and only json columns store numbers. + DB::statement('create table json_numbers (id integer primary key, array_field json, object_field json, array_object_field jsonb)'); + $id = JsonNumber::query()->insertGetId(['array_field' => '5', 'object_field' => '1.5', 'array_object_field' => '5']); + + $model = JsonNumber::query()->findOrFail($id); + + $this->assertSame(['id' => $id, 'array_field' => 5, 'object_field' => 1.5, 'array_object_field' => 5], $model->getRawOriginal()); + $this->assertSame(5, $model->array_field); + $this->assertSame(1.5, $model->object_field); + $this->assertNull($model->array_object_field); + + $model->array_field = 5; + $model->object_field = 1.5; + + $this->assertFalse($model->isDirty()); + + $model->array_field = 6; + $model->object_field = 2.5; + + $this->assertTrue($model->isDirty(['array_field', 'object_field'])); + $this->assertTrue($model->save()); + $this->assertSame(['id' => $id, 'array_field' => 6, 'object_field' => 2.5, 'array_object_field' => 5], JsonNumber::query()->findOrFail($id)->getRawOriginal()); + } + public function testValidAssignmentsCanReplaceMalformedStoredJson(): void { $casts = [ @@ -126,6 +155,21 @@ class JsonCast extends Model ]; } +class JsonNumber extends Model +{ + public ?string $table = 'json_numbers'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public array $casts = [ + 'array_field' => 'array', + 'object_field' => 'object', + 'array_object_field' => AsArrayObject::class, + ]; +} + enum JsonCastStatus: string { case Ready = 'ready'; diff --git a/tests/Integration/Database/Sqlite/SqliteTestCase.php b/tests/Integration/Database/Sqlite/SqliteTestCase.php index d06d8d3fb5..46093b65c6 100644 --- a/tests/Integration/Database/Sqlite/SqliteTestCase.php +++ b/tests/Integration/Database/Sqlite/SqliteTestCase.php @@ -6,6 +6,7 @@ use Hypervel\Database\SQLiteDatabase; use Hypervel\Filesystem\Filesystem; +use Hypervel\Support\Env; use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Tests\Integration\Database\DatabaseTestCase; use Override; @@ -24,7 +25,7 @@ protected function setUp(): void /** * Check if configured for in-memory SQLite database. * - * Uses env() so it can be called before the app boots. + * Reads the environment so it can be called before the app boots. */ protected function isConfiguredForInMemoryDatabase(): bool { @@ -34,11 +35,11 @@ protected function isConfiguredForInMemoryDatabase(): bool /** * Get the configured SQLite database path from env. * - * Uses env() so it can be called before the app boots. + * Reads the environment so it can be called before the app boots. */ protected function getConfiguredDatabasePath(): string { - return env('DB_DATABASE', ':memory:'); + return Env::get('DB_DATABASE', ':memory:'); } /** @@ -51,7 +52,7 @@ protected function ensureSqliteDatabaseFileExists(): void { // This runs before the class-level RequiresDatabase skip, so another driver's // database name must not be created as a file in the working directory. - if (env('DB_CONNECTION', 'sqlite') !== 'sqlite') { + if (Env::get('DB_CONNECTION', 'sqlite') !== 'sqlite') { return; } diff --git a/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php b/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php index 3abe75fd27..d47e3eb09b 100644 --- a/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php +++ b/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php @@ -6,10 +6,10 @@ use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\Bootstrap\LoadConfiguration; use Hypervel\Foundation\Console\ConfigPublishCommand; use Hypervel\Testbench\Concerns\InteractsWithPublishedFiles; use Mockery as m; -use ReflectionClass; use RuntimeException; use Symfony\Component\Console\Application as ConsoleApplication; use Symfony\Component\Console\Tester\CommandTester; @@ -37,7 +37,7 @@ protected function setUp(): void { parent::setUp(); - $this->baseConfigPath = dirname((new ReflectionClass(ConfigPublishCommand::class))->getFileName(), 3) . '/config'; + $this->baseConfigPath = LoadConfiguration::frameworkConfigPath(); } protected function tearDown(): void @@ -171,7 +171,7 @@ public function testForcedPublicationPreservesExistingPermissions(): void public function testSourceReadFailurePreservesExistingDestinationAndReportsNoSuccess(): void { $name = 'hashing'; - $source = dirname((new ReflectionClass(ConfigPublishCommand::class))->getFileName()) . "/../../config/{$name}.php"; + $source = "{$this->baseConfigPath}/{$name}.php"; $sourceContents = file_get_contents($source); $destination = $this->app->configPath("{$name}.php"); $this->preserveConfigFile($name); diff --git a/tests/Integration/Grpc/Fixtures/server.php b/tests/Integration/Grpc/Fixtures/server.php index 28df3830d2..a127e6989b 100644 --- a/tests/Integration/Grpc/Fixtures/server.php +++ b/tests/Integration/Grpc/Fixtures/server.php @@ -6,6 +6,7 @@ use Hypervel\Grpc\GrpcServiceProvider; use Hypervel\Grpc\Health\HealthStatusProvider; use Hypervel\Server\ServerFactory; +use Hypervel\Support\Env; use Hypervel\Testbench\Bootstrapper; use Hypervel\Testbench\Foundation\Application as TestbenchApplication; use Hypervel\Tests\Integration\Grpc\Fixtures\TestHealthStatusProvider; @@ -24,10 +25,10 @@ $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; -$port = (int) (env('GRPC_TEST_SERVER_PORT') ?: 19520); -$certificate = env('GRPC_TEST_SERVER_CERT'); -$privateKey = env('GRPC_TEST_SERVER_KEY'); -$compression = env('GRPC_TEST_SERVER_COMPRESSION'); +$port = (int) (Env::get('GRPC_TEST_SERVER_PORT') ?: 19520); +$certificate = Env::get('GRPC_TEST_SERVER_CERT'); +$privateKey = Env::get('GRPC_TEST_SERVER_KEY'); +$compression = Env::get('GRPC_TEST_SERVER_COMPRESSION'); $app = TestbenchApplication::create(); diff --git a/tests/Integration/Horizon/Feature/RedisPayloadTest.php b/tests/Integration/Horizon/Feature/RedisPayloadTest.php index bf68140c92..06b4aa825b 100644 --- a/tests/Integration/Horizon/Feature/RedisPayloadTest.php +++ b/tests/Integration/Horizon/Feature/RedisPayloadTest.php @@ -162,6 +162,17 @@ public function testTagsAreCorrectlyGatheredFromCollections(): void $this->assertEquals([FakeModel::class . ':1', FakeModel::class . ':2'], $JobPayload->decoded['tags']); } + public function testTagsAreGatheredFromParentPrivateProperties(): void + { + $payload = new JobPayload(json_encode(['id' => '1'])); + + $model = new FakeModel; + $model->id = 1; + + $payload->prepare(new FakeJobWithInheritedPrivateModel($model)); + $this->assertSame([FakeModel::class . ':1'], $payload->decoded['tags']); + } + public function testTagsAreCorrectlyExtractedForListeners(): void { $JobPayload = new JobPayload(json_encode(['id' => '1'])); @@ -341,3 +352,17 @@ public function tags(?object $event): array return [$event === null ? 'no-event' : 'stale-event']; } } + +class FakeJobWithPrivateModel +{ + /** + * Create a job holding its model privately. + */ + public function __construct(private FakeModel $model) + { + } +} + +class FakeJobWithInheritedPrivateModel extends FakeJobWithPrivateModel +{ +} diff --git a/tests/Integration/Queue/ModelSerializationTest.php b/tests/Integration/Queue/ModelSerializationTest.php index ddcaaf63b1..79c8503cf7 100644 --- a/tests/Integration/Queue/ModelSerializationTest.php +++ b/tests/Integration/Queue/ModelSerializationTest.php @@ -436,6 +436,26 @@ public function testModelSerializationStructure(): void $this->assertSame($this->expectedParentAccessibleSerialization(), $serialized); } + #[WithConfig('database.default', 'testing')] + public function testParentPrivatePropertiesAreSerializedAndRestored(): void + { + $user = ModelSerializationTestUser::create([ + 'email' => 'taylor@laravel.com', + ]); + + $serialized = serialize(new ModelSerializationPrivateChildTestClass($user, 'parent', 'child')); + + $user->update(['email' => 'taylor@hypervel.org']); + + /** @var ModelSerializationPrivateChildTestClass $unserialized */ + $unserialized = unserialize($serialized); + + // The parent's private model is restored from its identifier, so it reads the current row. + $this->assertSame('taylor@hypervel.org', $unserialized->parentUser()->email); + $this->assertSame('parent', $unserialized->parentMarker()); + $this->assertSame('child', $unserialized->childMarker()); + } + #[WithConfig('database.default', 'testing')] public function testItRespectsWithoutRelationsAttribute(): void { @@ -592,20 +612,28 @@ private function expectedParentAccessibleSerialization(): string $class = ModelSerializationParentAccessibleTestClass::class; $modelIdentifier = ModelIdentifier::class; $userClass = ModelSerializationTestUser::class; - - return sprintf( - 'O:%d:"%s":2:{s:4:"user";O:%d:"%s":5:{s:5:"class";s:%d:"%s";s:2:"id";i:1;s:9:"relations";a:0:{}s:10:"connection";s:7:"testing";s:15:"collectionClass";N;}s:8:"%s";O:%d:"%s":5:{s:5:"class";s:%d:"%s";s:2:"id";i:1;s:9:"relations";a:0:{}s:10:"connection";s:7:"testing";s:15:"collectionClass";N;}}', - strlen($class), - $class, + $user = sprintf( + 'O:%d:"%s":5:{s:5:"class";s:%d:"%s";s:2:"id";i:1;s:9:"relations";a:0:{}s:10:"connection";s:7:"testing";s:15:"collectionClass";N;}', strlen($modelIdentifier), $modelIdentifier, strlen($userClass), $userClass, + ); + // Hypervel: unlike Laravel, the parent's private user3 is serialized too, under PHP's key for it. + $parentPrivateKey = "\0" . ModelSerializationAccessibleTestClass::class . "\0user3"; + + return sprintf( + 'O:%d:"%s":3:{s:4:"user";%s' + . 's:8:"%s";%s' + . 's:%d:"%s";%s}', + strlen($class), + $class, + $user, "\0*\0user2", - strlen($modelIdentifier), - $modelIdentifier, - strlen($userClass), - $userClass, + $user, + strlen($parentPrivateKey), + $parentPrivateKey, + $user, ); } @@ -1005,6 +1033,55 @@ class ModelSerializationParentAccessibleTestClass extends ModelSerializationAcce { } +class ModelSerializationPrivateParentTestClass +{ + /** + * Create a fixture with private model and marker properties. + */ + public function __construct( + private ModelSerializationTestUser $user, + private string $marker, + ) { + } + + /** + * Get the parent's private user. + */ + public function parentUser(): ModelSerializationTestUser + { + return $this->user; + } + + /** + * Get the parent's private marker. + */ + public function parentMarker(): string + { + return $this->marker; + } +} + +class ModelSerializationPrivateChildTestClass extends ModelSerializationPrivateParentTestClass +{ + use SerializesModels; + + /** + * Create a fixture whose private marker shares the parent's property name. + */ + public function __construct(ModelSerializationTestUser $user, string $parentMarker, private string $marker) + { + parent::__construct($user, $parentMarker); + } + + /** + * Get the child's private marker. + */ + public function childMarker(): string + { + return $this->marker; + } +} + class ModelSerializationWithoutRelations { use SerializesModels; diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 3474c8c68b..8871ef0f06 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -302,7 +302,7 @@ public function testDisableLastRestartCheck(): void $cache = m::mock(Repository::class); $cache->shouldNotReceive('get')->with(Worker::RESTART_SIGNAL_CACHE_KEY); $cache->expects('get')->with('illuminate:queues:paused', false)->andReturn(false); - $cache->expects('many') + $cache->expects('get') ->with(['illuminate:queue:paused:database:default']) ->andReturn(['illuminate:queue:paused:database:default' => false]); @@ -332,7 +332,6 @@ public function testDisablePauseQueueCheck(): void $cache = m::mock(Repository::class); $cache->expects('get')->twice()->with(Worker::RESTART_SIGNAL_CACHE_KEY)->andReturn(null); - $cache->shouldNotReceive('many'); Cache::expects('store')->andReturn($cache); Cache::shouldNotReceive('driver'); diff --git a/tests/Integration/Reverb/Fixtures/server.php b/tests/Integration/Reverb/Fixtures/server.php index 9edc7d4f52..f56df8fe2e 100644 --- a/tests/Integration/Reverb/Fixtures/server.php +++ b/tests/Integration/Reverb/Fixtures/server.php @@ -27,6 +27,7 @@ use Hypervel\Reverb\Servers\Hypervel\ReverbRouter; use Hypervel\Reverb\Webhooks\Jobs\WebhookDeliveryJob; use Hypervel\Server\ServerFactory; +use Hypervel\Support\Env; use Hypervel\Support\Facades\Queue; use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\Bootstrapper; @@ -53,9 +54,9 @@ $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; // Read port, scaling flag, and worker count from environment. -$port = (string) (env('REVERB_SERVER_PORT') ?: '19510'); -$scaling = env('REVERB_SCALING_ENABLED') === true; -$workerNum = (int) (env('REVERB_TEST_WORKER_NUM') ?: 1); +$port = (string) (Env::get('REVERB_SERVER_PORT') ?: '19510'); +$scaling = Env::get('REVERB_SCALING_ENABLED') === true; +$workerNum = (int) (Env::get('REVERB_TEST_WORKER_NUM') ?: 1); $label = $scaling ? 'Reverb Redis test server' : ($workerNum > 1 ? "Reverb multi-worker test server ({$workerNum} workers)" : 'Reverb test server'); // Set Reverb app env vars BEFORE boot so the config file picks them up. @@ -77,7 +78,7 @@ ]; foreach ($defaults as $key => $value) { - if (env($key) === null) { + if (Env::get($key) === null) { putenv("{$key}={$value}"); $_ENV[$key] = $value; $_SERVER[$key] = $value; @@ -288,7 +289,7 @@ public function onMessage(\Swoole\WebSocket\Server $server, \Swoole\WebSocket\Fr }; }); -$port = env('REVERB_SERVER_PORT', 19510); +$port = Env::get('REVERB_SERVER_PORT', 19510); echo "Starting {$label} on 0.0.0.0:{$port}...\n"; diff --git a/tests/Integration/Support/ManagerTest.php b/tests/Integration/Support/ManagerTest.php index 690b74bfd5..92869966fc 100644 --- a/tests/Integration/Support/ManagerTest.php +++ b/tests/Integration/Support/ManagerTest.php @@ -10,6 +10,7 @@ use Hypervel\Testbench\TestCase; use Hypervel\Tests\Integration\Support\Fixtures\NullableManager; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use stdClass; class ManagerTest extends TestCase @@ -116,6 +117,36 @@ public function testNullAndEmptyStringSelectTheDefaultDriver(): void $this->assertSame('zero', $manager->driver(ManagerIntegerIdentifier::Zero)); } + #[DataProvider('namesWithoutACreatorMethodProvider')] + public function testNamesWithoutACreatorMethodAreNotSupported(?string $driver, string $message): void + { + $manager = new EmptyDefaultManager($this->app); + + $this->expectExceptionObject(new InvalidArgumentException($message)); + + $manager->driver($driver); + } + + public static function namesWithoutACreatorMethodProvider(): array + { + return [ + 'empty default' => [null, 'Driver [] not supported.'], + 'separator' => ['-', 'Driver [-] not supported.'], + 'whitespace' => [' ', 'Driver [ ] not supported.'], + ]; + } + + public function testNamesWithoutACreatorMethodCanBeRegistered(): void + { + $manager = new EmptyDefaultManager($this->app); + + $manager->extend('', fn (): string => 'empty'); + $manager->extend('-', fn (): string => 'separator'); + + $this->assertSame('empty', $manager->driver()); + $this->assertSame('separator', $manager->driver('-')); + } + public function testSetContainerRefreshesTheConfigurationRepository(): void { $manager = $this->createManager(); @@ -185,6 +216,17 @@ public function getConfigurationRepository(): Repository } } +class EmptyDefaultManager extends Manager +{ + /** + * Get the default driver name. + */ + public function getDefaultDriver(): string + { + return ''; + } +} + enum ManagerUnitIdentifier { case Primary; diff --git a/tests/ObjectPool/PoolErrorReporterTest.php b/tests/ObjectPool/PoolErrorReporterTest.php index 531048d3a0..fe34c3fdf1 100644 --- a/tests/ObjectPool/PoolErrorReporterTest.php +++ b/tests/ObjectPool/PoolErrorReporterTest.php @@ -6,52 +6,16 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; -use Hypervel\Filesystem\Filesystem; use Hypervel\ObjectPool\PoolErrorReporter; -use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use Mockery as m; use RuntimeException; -use Swoole\Coroutine\CanceledException; class PoolErrorReporterTest extends TestCase { protected bool $runTestsInCoroutine = false; - protected string $tempDir; - - protected string $errorLog; - - protected string|false|null $previousErrorLog = null; - - protected string|false|null $previousLogErrors = null; - - protected function setUp(): void - { - parent::setUp(); - - $this->tempDir = ParallelTesting::tempDir('PoolErrorReporterTest'); - (new Filesystem)->deleteDirectory($this->tempDir); - mkdir($this->tempDir, 0777, true); - $this->errorLog = $this->tempDir . '/php-error.log'; - } - - protected function tearDown(): void - { - if ($this->previousErrorLog !== null && $this->previousErrorLog !== false) { - ini_set('error_log', $this->previousErrorLog); - } - - if ($this->previousLogErrors !== null && $this->previousLogErrors !== false) { - ini_set('log_errors', $this->previousLogErrors); - } - - (new Filesystem)->deleteDirectory($this->tempDir); - - parent::tearDown(); - } - - public function testReportsThroughTheBoundExceptionHandler(): void + public function testReportsThroughTheSupportFailureReporter(): void { $container = new Container; Container::setInstance($container); @@ -61,79 +25,5 @@ public function testReportsThroughTheBoundExceptionHandler(): void $container->instance(ExceptionHandler::class, $handler); PoolErrorReporter::report($exception); - - $this->assertFileDoesNotExist($this->errorLog); - } - - public function testFallsBackToThePhpErrorLogWithoutAHandler(): void - { - Container::setInstance(new Container); - $this->routeErrorsToTempFile(); - - PoolErrorReporter::report(new RuntimeException('fallback without handler')); - - $this->assertStringContainsString('fallback without handler', $this->errorLogContents()); - } - - public function testHandlerFailureFallsBackWithoutPropagating(): void - { - $container = new Container; - Container::setInstance($container); - $exception = new RuntimeException('original cleanup failure'); - $handler = m::mock(ExceptionHandler::class); - $handler->shouldReceive('report')->once()->with($exception)->andThrow(new RuntimeException('handler failed')); - $container->instance(ExceptionHandler::class, $handler); - $this->routeErrorsToTempFile(); - - PoolErrorReporter::report($exception); - - $contents = $this->errorLogContents(); - $this->assertStringContainsString('original cleanup failure', $contents); - $this->assertStringNotContainsString('handler failed', $contents); - } - - public function testCancellationIsNotReportedOrLogged(): void - { - $container = new Container; - Container::setInstance($container); - $handler = m::mock(ExceptionHandler::class); - $handler->shouldNotReceive('report'); - $container->instance(ExceptionHandler::class, $handler); - $this->routeErrorsToTempFile(); - - PoolErrorReporter::report(new CanceledException('cleanup canceled')); - - $this->assertFileDoesNotExist($this->errorLog); - } - - public function testCancellationFromTheExceptionHandlerIsNotLogged(): void - { - $container = new Container; - Container::setInstance($container); - $exception = new RuntimeException('original cleanup failure'); - $handler = m::mock(ExceptionHandler::class); - $handler->shouldReceive('report')->once()->with($exception)->andThrow( - new CanceledException('reporting canceled') - ); - $container->instance(ExceptionHandler::class, $handler); - $this->routeErrorsToTempFile(); - - PoolErrorReporter::report($exception); - - $this->assertFileDoesNotExist($this->errorLog); - } - - private function errorLogContents(): string - { - $contents = file_get_contents($this->errorLog); - $this->assertIsString($contents); - - return $contents; - } - - private function routeErrorsToTempFile(): void - { - $this->previousErrorLog = ini_set('error_log', $this->errorLog); - $this->previousLogErrors = ini_set('log_errors', '1'); } } diff --git a/tests/OpenTelemetry/Instrumentation/CacheInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/CacheInstrumentationTest.php index 16a1c1f7e1..5f9be3288d 100644 --- a/tests/OpenTelemetry/Instrumentation/CacheInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/CacheInstrumentationTest.php @@ -219,7 +219,7 @@ public function testResolverFailureOmitsTheKeyWithoutSuppressingCompletionTeleme #[TestWith([false], 'direct cancellation')] #[TestWith([true], 'wrapped cancellation')] - public function testResolverCancellationPropagatesWithoutCompletionTelemetry(bool $wrapped): void + public function testResolverCancellationPropagatesAfterCountingTheOperation(bool $wrapped): void { $cancellation = $wrapped ? new RuntimeException('Resolver cleanup failed.', previous: new CanceledException) : new CanceledException; $resolver = static function () use ($cancellation): never { @@ -239,10 +239,12 @@ public function testResolverCancellationPropagatesWithoutCompletionTelemetry(boo $this->assertSame($cancellation, $caught); }); + // The key resolver only enriches the span event; the completed operation is counted first. $this->assertCount(0, $this->spanExporter->getSpans()[0]->getEvents()); $metric = $this->metric('hypervel.cache.operations'); $this->assertInstanceOf(Sum::class, $metric->data); - $this->assertCount(0, $metric->data->dataPoints); + $this->assertCount(1, $metric->data->dataPoints); + $this->assertSame('hit', $metric->data->dataPoints[0]->attributes->get('result')); } public function testMetricsOnlyModeSkipsCurrentSpanAndEveryKeyDependency(): void diff --git a/tests/OpenTelemetry/Instrumentation/ConsoleInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/ConsoleInstrumentationTest.php index b1567cc300..fc167b1f7c 100644 --- a/tests/OpenTelemetry/Instrumentation/ConsoleInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/ConsoleInstrumentationTest.php @@ -235,20 +235,28 @@ public function testThrowableIsRecordedAndHandedToExceptionTelemetry(): void ); } - public function testCancellationProducesNoFalseCompletionTelemetry(): void + public function testCanceledCommandEndsItsSpanWithoutAnOutcome(): void { $this->instrumentation()->register($this->options()); $command = new ConsoleInstrumentationCommand('reports:daily'); + $this->clock->timestamp = 1_000_000_000; $input = $this->startCommand($command); - $active = Span::getCurrent()->getContext(); + $this->clock->timestamp = 3_000_000_000; $this->finishCommand($command, $input, new CanceledException, 1); - $this->assertTrue($active->isValid()); - $this->assertSame($active->getSpanId(), Span::getCurrent()->getContext()->getSpanId()); - $this->assertSame([], $this->spanExporter->getSpans()); + $span = $this->exportedSpan('reports:daily'); + $this->assertSame(StatusCode::STATUS_UNSET, $span->getStatus()->getCode()); + $this->assertNull($span->getAttributes()->get('result')); + $this->assertNull($span->getAttributes()->get('hypervel.console.exit_code')); + $this->assertNull($span->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + $this->assertSame([], $span->getEvents()); + $this->assertFalse(Span::getCurrent()->getContext()->isValid()); $this->metricReader->collect(); - $this->assertCount(0, $this->metric(self::DURATION_METRIC)->data->dataPoints); + $point = $this->histogramPoint(); + $this->assertSame(2, $point->sum); + $this->assertNull($point->attributes->get('result')); + $this->assertNull($point->attributes->get('hypervel.console.exit_code')); } public function testNestedCommandsOnlyCompleteTheExactTopState(): void diff --git a/tests/OpenTelemetry/Instrumentation/GrpcInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/GrpcInstrumentationTest.php index 56f7bac4ca..8a03990a42 100644 --- a/tests/OpenTelemetry/Instrumentation/GrpcInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/GrpcInstrumentationTest.php @@ -362,32 +362,24 @@ public function testClientPropagationFailureClosesItsStartedTelemetryBeforeRethr ); } - public function testRuntimeCancellationProducesNoFalseCompletionTelemetry(): void + public function testRuntimeCancellationEndsTheSpanWithoutAnRpcStatus(): void { $this->registerInstrumentation(); - $cancellation = new CanceledException; - $debugScopesDisabled = $_SERVER['OTEL_PHP_DEBUG_SCOPES_DISABLED'] ?? null; - $_SERVER['OTEL_PHP_DEBUG_SCOPES_DISABLED'] = 'true'; - - try { - // Terminal cancellation deliberately abandons the activated scope under test. - $handle = $this->operations->start($this->serverOperation()); - } finally { - if ($debugScopesDisabled === null) { - unset($_SERVER['OTEL_PHP_DEBUG_SCOPES_DISABLED']); - } else { - $_SERVER['OTEL_PHP_DEBUG_SCOPES_DISABLED'] = $debugScopesDisabled; - } - } - $activeSpanId = Span::getCurrent()->getContext()->getSpanId(); + $handle = $this->operations->start($this->serverOperation()); - $handle->finish(new GrpcOperationResult(null, $cancellation, 0)); + $handle->finish(new GrpcOperationResult(null, new CanceledException, 0)); $this->assertSame($activeSpanId, Span::getCurrent()->getContext()->getSpanId()); - $this->assertSame([], $this->spanExporter->getSpans()); + $span = $this->exportedSpan('example.Echo/Say'); + $this->assertSame(TraceStatusCode::STATUS_UNSET, $span->getStatus()->getCode()); + $this->assertNull($span->getAttributes()->get('rpc.status_code')); + $this->assertNull($span->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + $this->assertSame([], $span->getEvents()); $this->metricReader->collect(); - $this->assertCount(0, $this->metric(self::SERVER_DURATION_METRIC)->data->dataPoints); + $point = $this->histogramPoint(self::SERVER_DURATION_METRIC); + $this->assertNull($point->attributes->get('rpc.status_code')); + $this->assertNull($point->attributes->get(ErrorAttributes::ERROR_TYPE)); } public function testMetricOnlyClientOutputDoesNoWorkForServerCalls(): void diff --git a/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php index ccf33e3076..848039abf9 100644 --- a/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php @@ -304,7 +304,7 @@ public function testAssociatesAnOrdinaryFailureWithTheEndedClientSpan(): void $this->assertSame(RuntimeException::class, $point->attributes->get(ErrorAttributes::ERROR_TYPE)); } - public function testPromiseCancellationPropagatesWithoutCompletionTelemetry(): void + public function testPromiseCancellationEndsTheSpanWithoutAnError(): void { $cancellation = new CanceledException; $this->factory->fake(static fn () => Create::rejectionFor($cancellation)); @@ -317,10 +317,16 @@ public function testPromiseCancellationPropagatesWithoutCompletionTelemetry(): v $this->assertSame($cancellation, $exception); } - $this->assertSame([], $this->spanExporter->getSpans()); + $spans = $this->spanExporter->getSpans(); + $this->assertCount(1, $spans); + $this->assertSame(StatusCode::STATUS_UNSET, $spans[0]->getStatus()->getCode()); + $this->assertNull($spans[0]->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + $this->assertNull($spans[0]->getAttributes()->get(HttpAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertSame([], $spans[0]->getEvents()); $this->metricReader->collect(); - $this->assertSame([], $this->metric(HttpMetrics::HTTP_CLIENT_REQUEST_DURATION)->data->dataPoints); - $this->assertSame(2_000_000_000, $this->clock->timestamp); + $points = $this->metric(HttpMetrics::HTTP_CLIENT_REQUEST_DURATION)->data->dataPoints; + $this->assertCount(1, $points); + $this->assertNull($points[0]->attributes->get(ErrorAttributes::ERROR_TYPE)); } #[TestWith([false], 'direct cancellation')] @@ -350,16 +356,14 @@ public function testUrlTemplateResolverCancellationStopsBeforeThePhysicalSend(bo $this->assertSame($cancellation, $caught); $this->assertSame(0, $physicalSends); $spans = $this->spanExporter->getSpans(); - $this->assertCount($wrapped ? 1 : 0, $spans); + $this->assertCount(1, $spans); - if ($wrapped) { - // Cancellation stops the request, but its real cleanup failure remains observable. - $this->assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); - $this->assertSame(RuntimeException::class, $spans[0]->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); - } + // Cancellation stops the request, but a real cleanup failure carrying it remains observable. + $this->assertSame($wrapped ? StatusCode::STATUS_ERROR : StatusCode::STATUS_UNSET, $spans[0]->getStatus()->getCode()); + $this->assertSame($wrapped ? RuntimeException::class : null, $spans[0]->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); $this->metricReader->collect(); - $this->assertCount($wrapped ? 1 : 0, $this->metric(HttpMetrics::HTTP_CLIENT_REQUEST_DURATION)->data->dataPoints); + $this->assertCount(1, $this->metric(HttpMetrics::HTTP_CLIENT_REQUEST_DURATION)->data->dataPoints); } public function testManualModeTracesOnlyMarkedRequestsWhileMetricsRemainIndependent(): void diff --git a/tests/OpenTelemetry/Instrumentation/HttpServerInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/HttpServerInstrumentationTest.php index f3ac334092..2e75046944 100644 --- a/tests/OpenTelemetry/Instrumentation/HttpServerInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/HttpServerInstrumentationTest.php @@ -8,6 +8,7 @@ use Hypervel\Events\Dispatcher; use Hypervel\Http\Request; use Hypervel\Http\Response; +use Hypervel\HttpServer\Events\RequestCanceled; use Hypervel\HttpServer\Events\RequestHandled; use Hypervel\HttpServer\Events\RequestReceived; use Hypervel\HttpServer\Events\ResponseSent; @@ -51,7 +52,9 @@ use OpenTelemetry\SemConv\Attributes\ServerAttributes; use OpenTelemetry\SemConv\Attributes\UrlAttributes; use OpenTelemetry\SemConv\Metrics\HttpMetrics; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; class HttpServerInstrumentationTest extends TestCase { @@ -255,6 +258,50 @@ public function testUsesWireMethodSemanticsAndReportsFailures(): void $this->assertSame('get', $lowercaseAttributes[HttpAttributes::HTTP_REQUEST_METHOD_ORIGINAL]); } + #[TestWith(['direct'])] + #[TestWith(['wrapped'])] + #[TestWith(['sent'])] + #[TestWith(['failure'])] + public function testCanceledRequestEndsItsSpanWithTheKnownOutcomeAndReleasesTheActiveRequest(string $case): void + { + $this->instrumentation()->register($this->options([ + 'metrics' => [ + HttpMetrics::HTTP_SERVER_REQUEST_DURATION => true, + 'http.server.active_requests' => true, + ], + ])); + $request = Request::create('/users', 'GET', server: ['HTTP_HOST' => 'example.test']); + $cancellation = $case === 'wrapped' + ? new RuntimeException('Cleanup failed.', previous: new CanceledException) + : new CanceledException; + $failure = $case === 'failure' ? new RuntimeException('Handled listener failed.') : null; + + $this->events->dispatch(new RequestReceived($request, null)); + $this->events->dispatch(new RequestCanceled( + $request, + $case === 'sent' ? new Response('OK') : null, + $cancellation, + $failure, + )); + + $spans = $this->spanExporter->getSpans(); + $this->assertCount(1, $spans); + $expectedError = match ($case) { + 'wrapped' => RuntimeException::class, + 'failure' => RuntimeException::class, + default => null, + }; + $this->assertSame($expectedError, $spans[0]->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + $this->assertSame( + $expectedError === null ? StatusCode::STATUS_UNSET : StatusCode::STATUS_ERROR, + $spans[0]->getStatus()->getCode(), + ); + $this->assertSame($case === 'sent' ? 200 : null, $spans[0]->getAttributes()->get(HttpAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->metricReader->collect(); + $this->assertSame(0, $this->metric('http.server.active_requests')->data->dataPoints[0]->value); + $this->assertCount(1, $this->metric(HttpMetrics::HTTP_SERVER_REQUEST_DURATION)->data->dataPoints); + } + public function testExcludedRequestsPerformNoTelemetryWork(): void { $this->instrumentation()->register($this->options([ diff --git a/tests/OpenTelemetry/Instrumentation/QueueInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/QueueInstrumentationTest.php index c7218e4a32..91bca5a446 100644 --- a/tests/OpenTelemetry/Instrumentation/QueueInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/QueueInstrumentationTest.php @@ -24,6 +24,7 @@ use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; +use Hypervel\Queue\Events\JobQueueingCanceled; use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Queue\Events\JobTimedOut; use Hypervel\Queue\Events\OutboxBatchDelivered; @@ -34,6 +35,7 @@ use Hypervel\Queue\TimeoutExceededException; use Hypervel\Tests\OpenTelemetry\Fixtures\CapturingAlwaysOffSampler; use Hypervel\Tests\TestCase; +use LogicException; use Mockery as m; use OpenTelemetry\API\Common\Time\ClockInterface; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; @@ -67,6 +69,7 @@ use OpenTelemetry\SemConv\Attributes\ErrorAttributes; use OpenTelemetry\SemConv\Attributes\ExceptionAttributes; use Override; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; use Throwable; @@ -508,13 +511,19 @@ public function testOrdinaryFinalizerFailureEndsAndAssociatesTheStartedSpanWitho $this->metricReader->collect(); $this->assertCount(0, $this->metric(self::SENT_MESSAGES_METRIC)->data->dataPoints); - $this->assertCount(0, $this->metric(self::SEND_DURATION_METRIC)->data->dataPoints); + $this->assertSame( + RuntimeException::class, + $this->histogramPoint(self::SEND_DURATION_METRIC)->attributes->get(ErrorAttributes::ERROR_TYPE), + ); } - public function testFinalizerCancellationAbandonsTheSpanAndCommitsNothing(): void + #[TestWith([true])] + #[TestWith([false], 'metrics only')] + public function testFinalizerCancellationEndsTheProducerWithoutAnErrorAndCommitsNothing(bool $traces): void { $cancellation = new CanceledException; - $this->instrumentation(new QueueInstrumentationTestPropagator($cancellation))->register($this->options()); + $this->exceptionContexts->enable(); + $this->instrumentation(new QueueInstrumentationTestPropagator($cancellation))->register($this->options(['traces' => $traces])); $event = $this->finalizingEvent(); $originalPayload = $event->payload; @@ -527,9 +536,55 @@ public function testFinalizerCancellationAbandonsTheSpanAndCommitsNothing(): voi $this->assertSame($originalPayload, $event->payload); $this->assertNull(QueueProducerStateStore::current()->take($originalPayload)); - $this->assertSame([], $this->spanExporter->getSpans()); + + if ($traces) { + $span = $this->exportedSpan('enqueue emails'); + $this->assertSame(StatusCode::STATUS_UNSET, $span->getStatus()->getCode()); + $this->assertSame([], $span->getEvents()); + } + + $this->assertNull($this->exceptionContexts->take($cancellation)); $this->metricReader->collect(); $this->assertCount(0, $this->metric(self::SENT_MESSAGES_METRIC)->data->dataPoints); + $this->assertFalse($this->histogramPoint(self::SEND_DURATION_METRIC)->attributes->has(ErrorAttributes::ERROR_TYPE)); + } + + #[TestWith(['before send', false, false, null])] + #[TestWith(['wrapped after send', true, true, RuntimeException::class])] + #[TestWith(['after a failure', true, false, LogicException::class])] + #[TestWith(['after queued', true, true, null])] + public function testQueueingCancellationEndsTheProducerWithTheKnownOutcome( + string $case, + bool $sendAttempted, + bool $wrapped, + ?string $expectedError, + ): void { + $this->instrumentation()->register($this->options([ + 'metrics' => $this->metrics(sent: true, sendDuration: true), + ])); + $event = $this->finalizingEvent(); + $this->events->dispatch($event); + $cancellation = $wrapped ? new RuntimeException('Cleanup failed.', previous: new CanceledException) : new CanceledException; + + $this->events->dispatch(new JobQueueingCanceled( + 'redis', + 'emails', + 'SendEmail@handle', + $event->payload, + null, + $cancellation, + $sendAttempted, + queued: $case === 'after queued', + exception: $case === 'after a failure' ? new LogicException('Send failed.') : null, + )); + + $span = $this->exportedSpan('enqueue emails'); + $this->assertSame($expectedError, $span->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + $this->assertSame($expectedError === null ? StatusCode::STATUS_UNSET : StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + $this->assertNull(QueueProducerStateStore::current()->take($event->payload)); + $this->metricReader->collect(); + $this->assertSame($expectedError, $this->histogramPoint(self::SEND_DURATION_METRIC)->attributes->get(ErrorAttributes::ERROR_TYPE)); + $this->assertCount($sendAttempted ? 1 : 0, $this->metric(self::SENT_MESSAGES_METRIC)->data->dataPoints); } public function testTraceDisabledPropagationFailureCommitsNoMetricState(): void @@ -604,7 +659,7 @@ public function testEarlierFinalizerFailureCreatesNoProducerState(): void $this->assertSame([], $this->spanExporter->getSpans()); } - public function testLaterFinalizerFailureCompletesMetricsOnlyProducerByUuid(): void + public function testLaterFinalizerFailureCompletesMetricsOnlyProducerByUuidWithoutCountingASend(): void { $exception = new RuntimeException('Later finalizer failed.'); $this->instrumentation()->register($this->options([ @@ -631,10 +686,10 @@ public function testLaterFinalizerFailureCompletesMetricsOnlyProducerByUuid(): v $this->assertFalse($queue->stored); $this->assertNull(QueueProducerStateStore::current()->takeUuid('job-uuid')); $this->metricReader->collect(); - $this->assertSame(1, $this->sumPoint(self::SENT_MESSAGES_METRIC)->value); + $this->assertCount(0, $this->metric(self::SENT_MESSAGES_METRIC)->data->dataPoints); $this->assertSame( RuntimeException::class, - $this->sumPoint(self::SENT_MESSAGES_METRIC)->attributes->get(ErrorAttributes::ERROR_TYPE), + $this->histogramPoint(self::SEND_DURATION_METRIC)->attributes->get(ErrorAttributes::ERROR_TYPE), ); } @@ -957,6 +1012,43 @@ public function testConsumerTimeoutUsesTheStableTimeoutErrorType(): void ); } + #[TestWith(['direct', null])] + #[TestWith(['wrapped', RuntimeException::class])] + #[TestWith(['timeout', TimeoutExceededException::class])] + #[TestWith(['after a failure', LogicException::class])] + public function testCanceledAttemptEndsTheConsumerSpanWithItsKnownOutcomeAndRestoresTheScope(string $case, ?string $expectedError): void + { + $this->instrumentation()->register($this->options([ + 'propagation' => false, + 'metrics' => $this->metrics(processDuration: true), + ])); + $job = $this->job($this->payload()); + $exception = match ($case) { + 'wrapped' => new RuntimeException('Cleanup failed.', previous: new CanceledException), + 'timeout' => new TimeoutExceededException('Job timed out.'), + default => new CanceledException, + }; + + $this->events->dispatch(new JobProcessing('redis', $job)); + $this->assertTrue(Span::getCurrent()->getContext()->isValid()); + + if ($case === 'after a failure') { + $this->events->dispatch(new JobExceptionOccurred('redis', $job, new LogicException('Job failed.'))); + } + + $this->events->dispatch(new JobAttempted('redis', $job, $exception, canceled: true)); + + $this->assertFalse(Span::getCurrent()->getContext()->isValid()); + $span = $this->exportedSpan('process emails'); + $this->assertSame($expectedError === null ? StatusCode::STATUS_UNSET : StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + $this->assertSame($expectedError, $span->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + $this->metricReader->collect(); + $this->assertSame( + $expectedError, + $this->histogramPoint(self::PROCESS_DURATION_METRIC)->attributes->get(ErrorAttributes::ERROR_TYPE), + ); + } + public function testLocalAsynchronousConnectionsReceiveFlatPropagationOnly(): void { $this->instrumentation( diff --git a/tests/OpenTelemetry/Instrumentation/RedisInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/RedisInstrumentationTest.php index 3090f03f6d..ef285d9ae1 100644 --- a/tests/OpenTelemetry/Instrumentation/RedisInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/RedisInstrumentationTest.php @@ -265,27 +265,36 @@ public function testResolverFailureIsIsolatedFromCommandTelemetry(): void $this->assertCount(1, $this->metric(DbMetrics::DB_CLIENT_OPERATION_DURATION)->data->dataPoints); } - #[TestWith([false], 'direct cancellation')] - #[TestWith([true], 'wrapped cancellation')] - public function testResolverCancellationPropagatesWithoutCompletionTelemetry(bool $wrapped): void + #[TestWith([false, false], 'direct cancellation after a successful command')] + #[TestWith([true, false], 'wrapped cancellation after a successful command')] + #[TestWith([false, true], 'direct cancellation after a failed command')] + public function testResolverCancellationPropagatesAndKeepsTheCommandOutcome(bool $wrapped, bool $commandFails): void { $cancellation = $wrapped ? new RuntimeException('Resolver cleanup failed.', previous: new CanceledException) : new CanceledException; $resolver = static function () use ($cancellation): never { throw $cancellation; }; $this->instrumentation(resolver: $resolver)->register($this->options()); + $exception = new RuntimeException('connection lost'); $caught = null; try { - $this->events->dispatch(new CommandExecuted('GET', ['customer:1'], 1, $this->connection())); - } catch (Throwable $exception) { - $caught = $exception; + $this->events->dispatch($commandFails + ? new CommandFailed('GET', ['customer:1'], $exception, $this->connection(), 50) + : new CommandExecuted('GET', ['customer:1'], 1, $this->connection())); + } catch (Throwable $throwable) { + $caught = $throwable; } $this->assertSame($cancellation, $caught); - $this->assertCount(0, $this->spanExporter->getSpans()); + $span = $this->spanExporter->getSpans()[0]; + $this->assertSame($commandFails ? StatusCode::STATUS_ERROR : StatusCode::STATUS_UNSET, $span->getStatus()->getCode()); + $this->assertSame($commandFails ? RuntimeException::class : null, $span->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + $this->assertNull($span->getAttributes()->get(DbAttributes::DB_QUERY_TEXT)); + $this->assertCount($commandFails ? 1 : 0, $span->getEvents()); $this->metricReader->collect(); - $this->assertCount(0, $this->metric(DbMetrics::DB_CLIENT_OPERATION_DURATION)->data->dataPoints); + $point = $this->metric(DbMetrics::DB_CLIENT_OPERATION_DURATION)->data->dataPoints[0]; + $this->assertSame($commandFails ? RuntimeException::class : null, $point->attributes->get(ErrorAttributes::ERROR_TYPE)); } public function testQueryTextCanBeDisabledWithoutFormatterOrResolverWork(): void diff --git a/tests/OpenTelemetry/Instrumentation/SchedulerInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/SchedulerInstrumentationTest.php index 67fb9c84d9..e7951beaac 100644 --- a/tests/OpenTelemetry/Instrumentation/SchedulerInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/SchedulerInstrumentationTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\OpenTelemetry\Instrumentation; use ArrayObject; +use Hypervel\Console\Events\ScheduledTaskCanceled; use Hypervel\Console\Events\ScheduledTaskFailed; use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Console\Events\ScheduledTaskStarting; @@ -44,6 +45,7 @@ use OpenTelemetry\SDK\Trace\TracerProvider; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; use OpenTelemetry\SemConv\Attributes\ExceptionAttributes; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -246,20 +248,39 @@ public function testFailureRecordsTheThrowableAndHandsItsExactContextToException $this->assertSame('failure', $this->sumPoint(self::EXECUTIONS_METRIC)->attributes->get('result')); } - public function testCancellationProducesNoFalseCompletionTelemetry(): void + #[TestWith(['direct', StatusCode::STATUS_UNSET, null])] + #[TestWith(['wrapped', StatusCode::STATUS_ERROR, 'failure'])] + #[TestWith(['failure', StatusCode::STATUS_ERROR, 'failure'])] + #[TestWith(['finished', StatusCode::STATUS_UNSET, 'success'])] + public function testCanceledTaskEndsItsSpanWithTheKnownOutcome(string $case, string $status, ?string $result): void { $this->instrumentation()->register($this->options()); $task = new SchedulerInstrumentationTask('reports:daily'); + $cancellation = $case === 'wrapped' + ? new RuntimeException('Cleanup failed.', previous: new CanceledException) + : new CanceledException; + $this->clock->timestamp = 1_000_000_000; $this->events->dispatch(new ScheduledTaskStarting($task)); - $active = Span::getCurrent()->getContext(); - $this->events->dispatch(new ScheduledTaskFailed($task, new CanceledException)); + $task->setTestExitCode(0); + $this->clock->timestamp = 3_000_000_000; + $this->events->dispatch(new ScheduledTaskCanceled( + $task, + $cancellation, + runtime: $case === 'finished' ? 2.0 : null, + exception: $case === 'failure' ? new RuntimeException('Task failed.') : null, + )); + + $span = $this->exportedSpan('schedule reports:daily'); + $this->assertSame($status, $span->getStatus()->getCode()); + $this->assertSame($result, $span->getAttributes()->get('result')); + $this->assertSame($case === 'finished' ? 0 : null, $span->getAttributes()->get('hypervel.scheduler.exit_code')); + $this->assertCount($case === 'direct' || $case === 'finished' ? 0 : 1, $span->getEvents()); + $this->assertFalse(Span::getCurrent()->getContext()->isValid()); - $this->assertTrue($active->isValid()); - $this->assertSame($active->getSpanId(), Span::getCurrent()->getContext()->getSpanId()); - $this->assertSame([], $this->spanExporter->getSpans()); $this->metricReader->collect(); - $this->assertCount(0, $this->metric(self::EXECUTIONS_METRIC)->data->dataPoints); + $this->assertSame($result, $this->sumPoint(self::EXECUTIONS_METRIC)->attributes->get('result')); + $this->assertSame(2, $this->histogramPoint(self::DURATION_METRIC)->sum); } public function testTaskIdentityIsAvailableToTheSampler(): void diff --git a/tests/OpenTelemetry/Instrumentation/ScoutInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/ScoutInstrumentationTest.php index 6ab1880501..c9911ef2b5 100644 --- a/tests/OpenTelemetry/Instrumentation/ScoutInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/ScoutInstrumentationTest.php @@ -219,34 +219,27 @@ public function testFailureRecordsErrorAndRestoresAmbientContext(): void ); } - public function testCancellationProducesNoFalseCompletionTelemetry(): void + public function testCancellationEndsTheSpanWithoutAnError(): void { $this->registerInstrumentation(); $cancellation = new CanceledException; - $caught = null; - $debugScopesDisabled = $_SERVER['OTEL_PHP_DEBUG_SCOPES_DISABLED'] ?? null; - $_SERVER['OTEL_PHP_DEBUG_SCOPES_DISABLED'] = 'true'; try { - // Terminal cancellation deliberately abandons the activated scope under test. - $this->operations->run($this->operation('search'), function () use ($cancellation): never { + $this->operations->run($this->operation('search'), static function () use ($cancellation): never { throw $cancellation; }); - } catch (Throwable $throwable) { - $caught = $throwable; - } finally { - if ($debugScopesDisabled === null) { - unset($_SERVER['OTEL_PHP_DEBUG_SCOPES_DISABLED']); - } else { - $_SERVER['OTEL_PHP_DEBUG_SCOPES_DISABLED'] = $debugScopesDisabled; - } + $this->fail('Cancellation was not rethrown.'); + } catch (CanceledException $caught) { + $this->assertSame($cancellation, $caught); } - $this->assertSame($cancellation, $caught); - $this->assertTrue(Span::getCurrent()->getContext()->isValid()); - $this->assertSame([], $this->spanExporter->getSpans()); + $this->assertFalse(Span::getCurrent()->getContext()->isValid()); + $span = $this->exportedSpan('search users'); + $this->assertSame(StatusCode::STATUS_UNSET, $span->getStatus()->getCode()); + $this->assertNull($span->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + $this->assertSame([], $span->getEvents()); $this->metricReader->collect(); - $this->assertCount(0, $this->metric()->data->dataPoints); + $this->assertNull($this->histogramPoint()->attributes->get(ErrorAttributes::ERROR_TYPE)); } public function testNestedOperationsUseTheActiveScoutSpanAsParent(): void diff --git a/tests/OpenTelemetry/Instrumentation/ViewInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/ViewInstrumentationTest.php index d0a26e7feb..8f1d1f9bb0 100644 --- a/tests/OpenTelemetry/Instrumentation/ViewInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/ViewInstrumentationTest.php @@ -232,19 +232,22 @@ function () use ($view): string { $this->assertCount(2, $this->spanExporter->getSpans()); } - public function testCancellationProducesNoFalseCompletionTelemetry(): void + public function testCancellationEndsTheSpanWithoutAnError(): void { $this->registerInstrumentation(); $view = $this->view('reports.daily'); + $active = Span::getCurrent()->getContext(); ($this->renderingObserver)($view); - $active = Span::getCurrent()->getContext(); ($this->renderedObserver)($view, new CanceledException); $this->assertSame($active->getSpanId(), Span::getCurrent()->getContext()->getSpanId()); - $this->assertSame([], $this->spanExporter->getSpans()); + $span = $this->exportedSpan('view reports.daily'); + $this->assertSame(StatusCode::STATUS_UNSET, $span->getStatus()->getCode()); + $this->assertNull($span->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + $this->assertSame([], $span->getEvents()); $this->metricReader->collect(); - $this->assertCount(0, $this->metric()->data->dataPoints); + $this->assertNull($this->histogramPoint()->attributes->get(ErrorAttributes::ERROR_TYPE)); } public function testMetricOnlyModeRecordsWithoutCreatingASpan(): void diff --git a/tests/OpenTelemetry/Instrumentation/WebSocketInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/WebSocketInstrumentationTest.php index a432401013..8788cc4597 100644 --- a/tests/OpenTelemetry/Instrumentation/WebSocketInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/WebSocketInstrumentationTest.php @@ -190,21 +190,22 @@ public function testFailureRecordsExceptionAndFailureDimensions(): void ); } - public function testCancellationProducesNoFalseCompletionTelemetry(): void + public function testCanceledMessageEndsItsSpanWithoutAResult(): void { $this->instrumentation()->register($this->options(activeConnections: false)); $frame = $this->frame(); $this->events->dispatch(new MessageReceived(1, $frame, 'websocket')); - $active = Span::getCurrent()->getContext(); $this->events->dispatch(new MessageHandled(1, $frame, 'websocket', new CanceledException)); - $this->assertTrue($active->isValid()); - $this->assertSame($active->getSpanId(), Span::getCurrent()->getContext()->getSpanId()); - $this->assertSame([], $this->spanExporter->getSpans()); + $span = $this->exportedSpan('websocket.message'); + $this->assertSame(StatusCode::STATUS_UNSET, $span->getStatus()->getCode()); + $this->assertNull($span->getAttributes()->get('result')); + $this->assertNull($span->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + $this->assertSame([], $span->getEvents()); + $this->assertFalse(Span::getCurrent()->getContext()->isValid()); $this->metricReader->collect(); - $this->assertCount(0, $this->metric(self::MESSAGES_METRIC)->data->dataPoints); - $this->assertSame(1, $this->clock->calls); + $this->assertNull($this->sumPoint(self::MESSAGES_METRIC)->attributes->get('result')); } public function testMessageIdentityIsAvailableToTheSampler(): void diff --git a/tests/OpenTelemetry/OpenTelemetryManagerTest.php b/tests/OpenTelemetry/OpenTelemetryManagerTest.php index 115a8c1cf7..46eea3b6d2 100644 --- a/tests/OpenTelemetry/OpenTelemetryManagerTest.php +++ b/tests/OpenTelemetry/OpenTelemetryManagerTest.php @@ -238,7 +238,7 @@ public function testTraceRecordsAndRethrowsTheSameException(): void $manager->shutdown(); } - public function testTraceDetachesAndRethrowsCancellationWithoutCompletingTheSpan(): void + public function testTraceEndsTheSpanWithoutAnErrorWhenCancellationStopsIt(): void { [$manager, $exporters] = $this->manager(); $manager->bind(ProcessIdentity::cli()); @@ -246,7 +246,7 @@ public function testTraceDetachesAndRethrowsCancellationWithoutCompletingTheSpan $cancellation = new CanceledException; try { - $manager->trace('cancelled', static function () use ($cancellation): never { + $manager->trace('canceled', static function () use ($cancellation): never { throw $cancellation; }); @@ -257,7 +257,11 @@ public function testTraceDetachesAndRethrowsCancellationWithoutCompletingTheSpan $this->assertSame($parent, Span::getCurrent()); $this->assertTrue($manager->flush()); - $this->assertSame([], $exporters->spanExporter->getSpans()); + $spans = $exporters->spanExporter->getSpans(); + $this->assertCount(1, $spans); + $this->assertSame('canceled', $spans[0]->getName()); + $this->assertSame(StatusCode::STATUS_UNSET, $spans[0]->getStatus()->getCode()); + $this->assertSame([], $spans[0]->getEvents()); $manager->shutdown(); } diff --git a/tests/PHPStan/AttributeServicesResultCacheMetaExtensionTest.php b/tests/PHPStan/AttributeServicesResultCacheMetaExtensionTest.php new file mode 100644 index 0000000000..fac07ecb48 --- /dev/null +++ b/tests/PHPStan/AttributeServicesResultCacheMetaExtensionTest.php @@ -0,0 +1,36 @@ +copyApplication(); + $extensionsPath = $applicationPath . '/extensions'; + mkdir($extensionsPath); + $configuration = "attributeServicesDirectories:\n - {$extensionsPath}"; + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath, $configuration), 0)); + + copy(__DIR__ . '/Fixtures/AttributeServices/IdentifierReturnTypeExtension.php', $extensionsPath . '/IdentifierReturnTypeExtension.php'); + + $this->assertSame(['Usage.php:14 return.type'], $this->diagnostics($this->analyse($applicationPath, $configuration), 1)); + + $process = $this->analyse($applicationPath, $configuration); + + $this->assertSame(['Usage.php:14 return.type'], $this->diagnostics($process, 1)); + $this->assertReanalyzed(0, $process); + } +} diff --git a/tests/PHPStan/BootstrapTest.php b/tests/PHPStan/BootstrapTest.php new file mode 100644 index 0000000000..a8ddbb60bd --- /dev/null +++ b/tests/PHPStan/BootstrapTest.php @@ -0,0 +1,232 @@ +tempDir = ParallelTesting::tempDir('PHPStanBootstrapTest'); + (new Filesystem)->deleteDirectory($this->tempDir); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + public function testPackageAnalysisBootsTestbenchInEveryProcess(): void + { + $packagePath = $this->tempDir . '/package'; + mkdir($packagePath); + file_put_contents($packagePath . '/testbench.yaml', 'env: []'); + // The provider is declared only in the package's own Composer metadata, as an + // installing application would discover it. + file_put_contents($packagePath . '/composer.json', json_encode([ + 'name' => 'hypervel-phpstan/bootstrap-fixture', + 'extra' => ['hypervel' => ['providers' => [BootstrapFixtureServiceProvider::class]]], + ], JSON_THROW_ON_ERROR)); + + $process = $this->analyse($packagePath, ['AspectGreeter.php', 'ReplacedGreeter.php', 'Usage.php']); + + // Aspect targets report against their original source, and the class-map + // replacement supplies replacementOnly(). + $this->assertSame([ + 'AspectGreeter.php:22 return.type', + 'Usage.php:14 argument.type', + ], $this->diagnostics($process)); + + $evidence = $this->evidence(); + + $this->assertGreaterThanOrEqual(2, count($evidence)); + $this->assertCount(count($evidence), array_unique(array_column($evidence, 'pid'))); + + foreach ($evidence as $boot) { + $this->assertTrue($boot['booted']); + $this->assertTrue($boot['macro']); + $this->assertStringContainsString('/hypervel-components-testbench-', $boot['basePath']); + $this->assertDirectoryDoesNotExist($boot['basePath']); + } + } + + public function testComponentsAnalysisBootsItsOwnProviders(): void + { + $repositoryPath = dirname(__DIR__, 2); + + // Testbench's contract-suite configuration, which Testbench selects for the components repo, excludes them. + $process = $this->runBootstrap($repositoryPath, code: <<<'PHP' + echo json_encode([ + Hypervel\Testbench\Bootstrapper::getConfiguration()?->getConfigurationPath(), + isset(Hypervel\Support\Facades\Facade::getFacadeApplication()->getLoadedProviders()[Hypervel\Telescope\TelescopeServiceProvider::class]), + ]); + PHP); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertSame([$repositoryPath, true], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + } + + public function testApplicationAnalysisBootsTheApplication(): void + { + $applicationPath = $this->tempDir . '/application'; + (new Filesystem)->copyDirectory(__DIR__ . '/Fixtures/Bootstrap/application', $applicationPath); + mkdir($applicationPath . '/bootstrap/cache'); + + $process = $this->analyse($applicationPath, ['Usage.php']); + + $this->assertSame(['Usage.php:14 argument.type'], $this->diagnostics($process)); + + foreach ($this->evidence() as $boot) { + $this->assertTrue($boot['booted']); + $this->assertTrue($boot['macro']); + $this->assertSame($applicationPath, $boot['basePath']); + } + } + + public function testInvalidApplicationBootstrapFails(): void + { + $applicationPath = $this->tempDir . '/application'; + mkdir($applicationPath . '/bootstrap', 0777, true); + file_put_contents($applicationPath . '/bootstrap/app.php', 'runBootstrap($applicationPath); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringContainsString( + "The application bootstrap file [{$applicationPath}/bootstrap/app.php] must return the Hypervel application.", + $process->getErrorOutput(), + ); + } + + public function testBootFailureReportsTheRuntimeRequirement(): void + { + $packagePath = $this->tempDir . '/package'; + mkdir($packagePath); + + $process = $this->runBootstrap($packagePath, ['-d', 'swoole.use_shortname=On']); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringContainsString( + 'Swoole short function names must be disabled. Set swoole.use_shortname=Off in your php.ini.', + $process->getErrorOutput(), + ); + } + + /** + * Run the analyzer's bootstrap file from the working path. + * + * PHPStan only requires this file, so its boot needs no analysis run or container + * compilation. The code runs after it. + * + * @param list $phpOptions + */ + protected function runBootstrap(string $workingPath, array $phpOptions = [], string $code = ''): Process + { + $repositoryPath = dirname(__DIR__, 2); + + $process = new Process( + [PHP_BINARY, ...$phpOptions, '-r', 'require $argv[1]; require $argv[2]; ' . $code, $repositoryPath . '/vendor/autoload.php', $repositoryPath . '/src/phpstan/bootstrap.php'], + $workingPath, + timeout: 60, + ); + $process->run(); + + return $process; + } + + /** + * Run PHPStan from the working path over the given bootstrap fixtures. + * + * @param list $files + */ + protected function analyse(string $workingPath, array $files): Process + { + $repositoryPath = dirname(__DIR__, 2); + $fixturesPath = __DIR__ . '/Fixtures/Bootstrap'; + $paths = implode('', array_map( + static fn (string $file): string => "\n - {$fixturesPath}/{$file}", + $files, + )); + + file_put_contents($workingPath . '/phpstan.neon', << $this->tempDir . '/evidence.jsonl'], + timeout: 300, + ); + $process->run(); + + return $process; + } + + /** + * Get the file diagnostics as "file:line identifier" entries. + * + * @return list + */ + protected function diagnostics(Process $process): array + { + $this->assertSame(1, $process->getExitCode(), $process->getErrorOutput()); + + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([], $result['errors']); + + $diagnostics = []; + + foreach ($result['files'] as $file => $errors) { + foreach ($errors['messages'] as $message) { + $diagnostics[] = basename($file) . ":{$message['line']} {$message['identifier']}"; + } + } + + sort($diagnostics); + + return $diagnostics; + } + + /** + * Get the boot evidence recorded by each PHPStan process. + * + * @return list + */ + protected function evidence(): array + { + $lines = file($this->tempDir . '/evidence.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + + $this->assertNotEmpty($lines); + + return array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + $lines, + ); + } +} diff --git a/tests/PHPStan/ConfigCompositionTest.php b/tests/PHPStan/ConfigCompositionTest.php new file mode 100644 index 0000000000..bb0284b289 --- /dev/null +++ b/tests/PHPStan/ConfigCompositionTest.php @@ -0,0 +1,25 @@ +copyApplication(); + (new Filesystem)->copyDirectory(__DIR__ . '/Fixtures/Config/application', $applicationPath . '/config'); + + // Each assertType() call that does not hold is reported. + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } +} diff --git a/tests/PHPStan/Fixtures/AttributeServices/IdentifierReturnTypeExtension.php b/tests/PHPStan/Fixtures/AttributeServices/IdentifierReturnTypeExtension.php new file mode 100644 index 0000000000..04f9e8459a --- /dev/null +++ b/tests/PHPStan/Fixtures/AttributeServices/IdentifierReturnTypeExtension.php @@ -0,0 +1,33 @@ +getName() === 'AttributeServices\identifier'; + } + + /** + * Get the integer type of the identifier. + */ + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + return new IntegerType; + } +} diff --git a/tests/PHPStan/Fixtures/AttributeServices/application/app/Usage.php b/tests/PHPStan/Fixtures/AttributeServices/application/app/Usage.php new file mode 100644 index 0000000000..a3343de2ff --- /dev/null +++ b/tests/PHPStan/Fixtures/AttributeServices/application/app/Usage.php @@ -0,0 +1,15 @@ +create(); diff --git a/tests/PHPStan/Fixtures/AttributeServices/application/bootstrap/autoload.php b/tests/PHPStan/Fixtures/AttributeServices/application/bootstrap/autoload.php new file mode 100644 index 0000000000..0b146695f8 --- /dev/null +++ b/tests/PHPStan/Fixtures/AttributeServices/application/bootstrap/autoload.php @@ -0,0 +1,10 @@ +addPsr4('AttributeServices\Extensions\\', dirname(__DIR__) . '/extensions'); +$loader->register(); diff --git a/tests/PHPStan/Fixtures/Auth/CreatingAuthManager.php b/tests/PHPStan/Fixtures/Auth/CreatingAuthManager.php new file mode 100644 index 0000000000..09d0735c85 --- /dev/null +++ b/tests/PHPStan/Fixtures/Auth/CreatingAuthManager.php @@ -0,0 +1,22 @@ + null, $this->app, $this->createUserProvider($config['provider'] ?? null)); + } +} diff --git a/tests/PHPStan/Fixtures/Auth/ProvidingAuthManager.php b/tests/PHPStan/Fixtures/Auth/ProvidingAuthManager.php new file mode 100644 index 0000000000..e150f6a061 --- /dev/null +++ b/tests/PHPStan/Fixtures/Auth/ProvidingAuthManager.php @@ -0,0 +1,22 @@ +aspects(GreetingAspect::class); + $this->classMap([ReplacedGreeter::class => __DIR__ . '/replacement/ReplacedGreeter.php']); + } + + /** + * Register the fixture macro. + */ + public function boot(): void + { + Str::macro('phpstanBootstrapFixture', static fn (): string => 'booted'); + } +} diff --git a/tests/PHPStan/Fixtures/Bootstrap/GreetingAspect.php b/tests/PHPStan/Fixtures/Bootstrap/GreetingAspect.php new file mode 100644 index 0000000000..4f220838dd --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/GreetingAspect.php @@ -0,0 +1,21 @@ +process(); + } +} diff --git a/tests/PHPStan/Fixtures/Bootstrap/ReplacedGreeter.php b/tests/PHPStan/Fixtures/Bootstrap/ReplacedGreeter.php new file mode 100644 index 0000000000..234354031c --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/ReplacedGreeter.php @@ -0,0 +1,16 @@ +greet(42); + + return $replacedGreeter->replacementOnly(); + } +} diff --git a/tests/PHPStan/Fixtures/Bootstrap/application/bootstrap/app.php b/tests/PHPStan/Fixtures/Bootstrap/application/bootstrap/app.php new file mode 100644 index 0000000000..bf6b4bcd0d --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/application/bootstrap/app.php @@ -0,0 +1,10 @@ +withProviders([BootstrapFixtureServiceProvider::class]) + ->create(); diff --git a/tests/PHPStan/Fixtures/Bootstrap/evidence.php b/tests/PHPStan/Fixtures/Bootstrap/evidence.php new file mode 100644 index 0000000000..82b4fb7e5f --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/evidence.php @@ -0,0 +1,21 @@ + getmypid(), + 'application' => $app instanceof Application, + 'basePath' => $app instanceof Application ? $app->basePath() : null, + 'booted' => $app instanceof Application && $app->isBooted(), + 'macro' => Str::hasMacro('phpstanBootstrapFixture'), + ], JSON_THROW_ON_ERROR) . PHP_EOL, + FILE_APPEND | LOCK_EX, +); diff --git a/tests/PHPStan/Fixtures/Bootstrap/replacement/ReplacedGreeter.php b/tests/PHPStan/Fixtures/Bootstrap/replacement/ReplacedGreeter.php new file mode 100644 index 0000000000..fddde216bd --- /dev/null +++ b/tests/PHPStan/Fixtures/Bootstrap/replacement/ReplacedGreeter.php @@ -0,0 +1,24 @@ +mergeConfigFrom(__DIR__ . '/config/package.php', self::PACKAGE_KEY); + $this->mergeConfigFrom(__DIR__ . '/config/merged.php', 'merged'); + $this->replaceConfigRecursivelyFrom(__DIR__ . '/config/recursive.php', 'recursive'); + $this->mergeConfigFrom(__DIR__ . '/config/overlap.php', 'overlap'); + $this->mergeConfigFrom(__DIR__ . '/config/partial.php', 'partial'); + $this->mergeConfigFrom(__DIR__ . '/config/open.php', 'open'); + $this->replaceConfigRecursivelyFrom(__DIR__ . '/config/open-recursive.php', 'open-recursive'); + $this->mergeConfigFrom(__DIR__ . '/config/listed.php', 'listed'); + $this->replaceConfigRecursivelyFrom(__DIR__ . '/config/replaced.php', 'replaced'); + $this->mergeConfigFrom(__DIR__ . '/config/hashing.php', 'hashing'); + $this->mergeConfigFrom(__DIR__ . '/config/child.php', 'optional-child.child'); + $this->mergeConfigFrom(__DIR__ . '/config/child.php', 'open-parent.child'); + $this->mergeConfigFrom(__DIR__ . '/config/child.php', 'open-parent.missing.child'); + $this->registerPropertyConfig(); + + // Only the tracker sees this write: the static pass does not follow locals. + $config = $this->app->make('config'); + $config->set('package.direct', 'value'); + + $this->configureUsing(static function (Repository $config): void { + $config->set('package.written', $config->string('package.key')); + }); + + $this->configureUsing(static function (): void { + app('config')->set('package.helper', 'value'); + }); + + $this->publishes([__DIR__ . '/config/published.php' => config_path('published.php')]); + + if ($this->app->runningInConsole()) { + $this->mergeConfigFrom(__DIR__ . '/config/conditional.php', 'conditional'); + } + + // The analysis boot runs in the console, so only the static pass sees this write. + if (! $this->app->runningInConsole()) { + $this->app->make('config')->set('package.web', 'value'); + + return; + } + + $this->mergeConfigFrom(__DIR__ . '/config/conditional.php', 'after-return'); + } + + /** + * Write through a repository parameter, as the gRPC provider appends its server. + */ + protected function writeThroughParameter(Repository $config): void + { + $config->set('package.parameter', 'value'); + } + + /** + * Write a named entry with a dynamic name, as packages register disks. + */ + protected function registerDisk(string $name): void + { + config(['merged.disks.' . $name => ['driver' => 'local']]); + } + + /** + * Write a key whose last segment is only partly constant. + */ + protected function registerSuffixed(string $suffix): void + { + config(["partial.disks{$suffix}" => true]); + } + + /** + * Merge the config file keyed by the static property, from a path built with named arguments out of order. + */ + protected function registerPropertyConfig(): void + { + $this->mergeConfigFrom(dirname(levels: 1, path: __DIR__) . '/Config/config/property.php', static::$propertyKey); + } + + /** + * Get the package configuration's named-entry options. + */ + protected function mergeableOptions(string $name): array + { + return $name === self::PACKAGE_KEY ? ['stores'] : []; + } +} diff --git a/tests/PHPStan/Fixtures/Config/OverlappingConfigServiceProvider.php b/tests/PHPStan/Fixtures/Config/OverlappingConfigServiceProvider.php new file mode 100644 index 0000000000..8d1e3f5bd3 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/OverlappingConfigServiceProvider.php @@ -0,0 +1,18 @@ +mergeConfigFrom(__DIR__ . '/config/overlap.php', 'overlap'); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/CallableCreationProvider.php b/tests/PHPStan/Fixtures/Config/Writes/CallableCreationProvider.php new file mode 100644 index 0000000000..7b8f34f06d --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/CallableCreationProvider.php @@ -0,0 +1,21 @@ +set(...); + $make = $this->app->make(...); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/CallbackCallableProvider.php b/tests/PHPStan/Fixtures/Config/Writes/CallbackCallableProvider.php new file mode 100644 index 0000000000..ccfad4d4c5 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/CallbackCallableProvider.php @@ -0,0 +1,22 @@ +configureUsing(static function (Repository $config): void { + $set = $config->set(...); + $set('package.key', 'value'); + }); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/DynamicCallbackWriteProvider.php b/tests/PHPStan/Fixtures/Config/Writes/DynamicCallbackWriteProvider.php new file mode 100644 index 0000000000..4fd409ac0f --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/DynamicCallbackWriteProvider.php @@ -0,0 +1,21 @@ +configureUsing(static function (Repository $config): void { + $config->set($config->string('package.target'), true); + }); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/DynamicDirectWriteProvider.php b/tests/PHPStan/Fixtures/Config/Writes/DynamicDirectWriteProvider.php new file mode 100644 index 0000000000..162e20bc50 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/DynamicDirectWriteProvider.php @@ -0,0 +1,18 @@ +app->environment() => true]); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/DynamicMergeKeyProvider.php b/tests/PHPStan/Fixtures/Config/Writes/DynamicMergeKeyProvider.php new file mode 100644 index 0000000000..eb307acb7e --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/DynamicMergeKeyProvider.php @@ -0,0 +1,20 @@ +mergeConfigFrom(dirname(__DIR__) . '/config/package.php', $this->app->environment()); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/EscapingCallbackProvider.php b/tests/PHPStan/Fixtures/Config/Writes/EscapingCallbackProvider.php new file mode 100644 index 0000000000..79e1b9abc8 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/EscapingCallbackProvider.php @@ -0,0 +1,28 @@ +configureUsing(static function (Repository $config): void { + static::configure($config); + }); + } + + /** + * Change the configuration. + */ + protected static function configure(object $config): void + { + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/MergeCallableProvider.php b/tests/PHPStan/Fixtures/Config/Writes/MergeCallableProvider.php new file mode 100644 index 0000000000..899d3e7d04 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/MergeCallableProvider.php @@ -0,0 +1,32 @@ +mergeConfigFrom(...); + $merge(dirname(__DIR__) . '/config/package.php', 'package'); + + $this->mergePartialConfig(...); + } + + /** + * Merge the partial configuration. + */ + protected function mergePartialConfig(): void + { + $this->mergeConfigFrom(dirname(__DIR__) . '/config/partial.php', 'partial'); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/ReadCallableCallbackProvider.php b/tests/PHPStan/Fixtures/Config/Writes/ReadCallableCallbackProvider.php new file mode 100644 index 0000000000..26f4bc84f8 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/ReadCallableCallbackProvider.php @@ -0,0 +1,22 @@ +configureUsing(static function (Repository $config): void { + $get = $config->get(...); + $config->set('package.copy', $get('package.original')); + }); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/ReorderedArgumentsProvider.php b/tests/PHPStan/Fixtures/Config/Writes/ReorderedArgumentsProvider.php new file mode 100644 index 0000000000..e7ec2a410e --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/ReorderedArgumentsProvider.php @@ -0,0 +1,32 @@ +configureUsing(static function (Repository $config): void { + $config->set(value: 'text', key: 'package.callback'); + $config->offsetSet(value: 'text', offset: 'package.offset'); + }); + } + + /** + * Write through the helper and repositories resolved with reordered arguments. + */ + protected function writeDirectly(): void + { + config(default: null, key: ['package.helper' => 'text']); + app(parameters: [], abstract: 'config')->set('package.resolved', 'text'); + $this->app->make(parameters: [], abstract: 'config')->set('package.made', 'text'); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/UnpackedCallbackWriteProvider.php b/tests/PHPStan/Fixtures/Config/Writes/UnpackedCallbackWriteProvider.php new file mode 100644 index 0000000000..71dfd7354c --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/UnpackedCallbackWriteProvider.php @@ -0,0 +1,21 @@ +configureUsing(static function (Repository $config): void { + $config->set(...['package.key', 'value']); + }); + } +} diff --git a/tests/PHPStan/Fixtures/Config/Writes/UnrecognizedFlowProvider.php b/tests/PHPStan/Fixtures/Config/Writes/UnrecognizedFlowProvider.php new file mode 100644 index 0000000000..6614b436ea --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/Writes/UnrecognizedFlowProvider.php @@ -0,0 +1,29 @@ +configureUsing(static function (): void { + app(LoggerInterface::class)->info('configured'); + }); + + if ($this->app->runningInConsole()) { + $this->app->make('events')->dispatch('configured'); + } + + // The tracker records this write when it runs; the static pass does not follow locals. + $config = $this->app->make('config'); + $config->set($this->app->environment(), true); + } +} diff --git a/tests/PHPStan/Fixtures/Config/application/listed.php b/tests/PHPStan/Fixtures/Config/application/listed.php new file mode 100644 index 0000000000..c119f393d7 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/listed.php @@ -0,0 +1,5 @@ + 'app', + 'app' => 'app', +]; diff --git a/tests/PHPStan/Fixtures/Config/application/open-parent.php b/tests/PHPStan/Fixtures/Config/application/open-parent.php new file mode 100644 index 0000000000..11d6f9702b --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/open-parent.php @@ -0,0 +1,8 @@ + 'app', +]; diff --git a/tests/PHPStan/Fixtures/Config/application/open-recursive.php b/tests/PHPStan/Fixtures/Config/application/open-recursive.php new file mode 100644 index 0000000000..11d6f9702b --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/open-recursive.php @@ -0,0 +1,8 @@ + 'app', +]; diff --git a/tests/PHPStan/Fixtures/Config/application/open.php b/tests/PHPStan/Fixtures/Config/application/open.php new file mode 100644 index 0000000000..11d6f9702b --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/open.php @@ -0,0 +1,8 @@ + 'app', +]; diff --git a/tests/PHPStan/Fixtures/Config/application/optional-child.php b/tests/PHPStan/Fixtures/Config/application/optional-child.php new file mode 100644 index 0000000000..29762f58d9 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/optional-child.php @@ -0,0 +1,6 @@ + [ + 'shared' => 'app', + 'app' => 'app', + ], +]; diff --git a/tests/PHPStan/Fixtures/Config/application/replaced.php b/tests/PHPStan/Fixtures/Config/application/replaced.php new file mode 100644 index 0000000000..c119f393d7 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/application/replaced.php @@ -0,0 +1,5 @@ + 'package', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/conditional.php b/tests/PHPStan/Fixtures/Config/config/conditional.php new file mode 100644 index 0000000000..a5eb1539c9 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/conditional.php @@ -0,0 +1,7 @@ + 'conditional', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/hashing.php b/tests/PHPStan/Fixtures/Config/config/hashing.php new file mode 100644 index 0000000000..433e4e36a8 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/hashing.php @@ -0,0 +1,8 @@ + 'package', + 'package' => 'package', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/listed.php b/tests/PHPStan/Fixtures/Config/config/listed.php new file mode 100644 index 0000000000..36e900f4b8 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/listed.php @@ -0,0 +1,5 @@ + 'package', + 'package' => 'package', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/open-recursive.php b/tests/PHPStan/Fixtures/Config/config/open-recursive.php new file mode 100644 index 0000000000..6937423f97 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/open-recursive.php @@ -0,0 +1,8 @@ + 'package', + 'enabled' => false, +]; diff --git a/tests/PHPStan/Fixtures/Config/config/open.php b/tests/PHPStan/Fixtures/Config/config/open.php new file mode 100644 index 0000000000..6937423f97 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/open.php @@ -0,0 +1,8 @@ + 'package', + 'enabled' => false, +]; diff --git a/tests/PHPStan/Fixtures/Config/config/overlap.php b/tests/PHPStan/Fixtures/Config/config/overlap.php new file mode 100644 index 0000000000..3b750525f1 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/overlap.php @@ -0,0 +1,7 @@ + 'package', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/package.php b/tests/PHPStan/Fixtures/Config/config/package.php new file mode 100644 index 0000000000..8da54e9f40 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/package.php @@ -0,0 +1,12 @@ + 'package', + 'stores' => [ + 'declared' => ['driver' => 'array'], + ], + 'env' => env('PACKAGE_ENV'), + 'url' => config('app.url'), +]; diff --git a/tests/PHPStan/Fixtures/Config/config/partial.php b/tests/PHPStan/Fixtures/Config/config/partial.php new file mode 100644 index 0000000000..b2f7f56745 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/partial.php @@ -0,0 +1,7 @@ + 'value', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/property.php b/tests/PHPStan/Fixtures/Config/config/property.php new file mode 100644 index 0000000000..674447f763 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/property.php @@ -0,0 +1,7 @@ + 'property', +]; diff --git a/tests/PHPStan/Fixtures/Config/config/published.php b/tests/PHPStan/Fixtures/Config/config/published.php new file mode 100644 index 0000000000..1461805536 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/published.php @@ -0,0 +1,7 @@ + env('PUBLISHED_ENV'), +]; diff --git a/tests/PHPStan/Fixtures/Config/config/recursive.php b/tests/PHPStan/Fixtures/Config/config/recursive.php new file mode 100644 index 0000000000..f11c34c380 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/recursive.php @@ -0,0 +1,10 @@ + [ + 'shared' => 'package', + 'package' => 'package', + ], +]; diff --git a/tests/PHPStan/Fixtures/Config/config/replaced.php b/tests/PHPStan/Fixtures/Config/config/replaced.php new file mode 100644 index 0000000000..36e900f4b8 --- /dev/null +++ b/tests/PHPStan/Fixtures/Config/config/replaced.php @@ -0,0 +1,5 @@ +string('package.key')); + assertType("'app'", Config::get('merged.shared')); +} diff --git a/tests/PHPStan/Fixtures/ConfigComposition/application/bootstrap/app.php b/tests/PHPStan/Fixtures/ConfigComposition/application/bootstrap/app.php new file mode 100644 index 0000000000..72b7328c5b --- /dev/null +++ b/tests/PHPStan/Fixtures/ConfigComposition/application/bootstrap/app.php @@ -0,0 +1,11 @@ +withProviders([ConfigFixtureServiceProvider::class, OverlappingConfigServiceProvider::class]) + ->create(); diff --git a/tests/PHPStan/Fixtures/ConfigComposition/application/bootstrap/autoload.php b/tests/PHPStan/Fixtures/ConfigComposition/application/bootstrap/autoload.php new file mode 100644 index 0000000000..0a5429f16d --- /dev/null +++ b/tests/PHPStan/Fixtures/ConfigComposition/application/bootstrap/autoload.php @@ -0,0 +1,5 @@ +argument('argument')); + + if ($this->hasArgument('argument')) { + $this->argument('argument'); + } + + if ($this->hasOption('option')) { + $this->option('option'); + } + + if ($this->hasArgument(name: 'argument')) { + $this->argument('argument'); + } + + if ($this->hasOption('option')) { + $this->option(key: 'option'); + } + + $this->hasArgument('missing') && $this->argument('missing'); + } +} diff --git a/tests/PHPStan/Fixtures/Console/WithArgumentCommand.php b/tests/PHPStan/Fixtures/Console/WithArgumentCommand.php new file mode 100644 index 0000000000..27cb98de5b --- /dev/null +++ b/tests/PHPStan/Fixtures/Console/WithArgumentCommand.php @@ -0,0 +1,31 @@ +', $bar->argument('argumentArray')); + assertType('mixed', $this->option('alias')); + } +} diff --git a/tests/PHPStan/Fixtures/Console/WithoutArgumentCommand.php b/tests/PHPStan/Fixtures/Console/WithoutArgumentCommand.php new file mode 100644 index 0000000000..521664ae1e --- /dev/null +++ b/tests/PHPStan/Fixtures/Console/WithoutArgumentCommand.php @@ -0,0 +1,10 @@ +> $rows + */ + public function export(array $rows): string + { + return ''; + } + + /** + * Include a header row. + */ + public function withHeader(): static + { + return $this; + } + + /** + * Export into the given buffer. + */ + public function exportInto(string &$buffer): void + { + $buffer .= ''; + } + + /** + * Count the exported rows, which the manager documents differently. + */ + public function count(): string + { + return ''; + } + + /** + * Create a driver. + */ + public static function make(): self + { + return new self; + } + + /** + * Format a row. + */ + protected function format(): string + { + return ''; + } +} diff --git a/tests/PHPStan/Fixtures/Managers/ExportManager.php b/tests/PHPStan/Fixtures/Managers/ExportManager.php new file mode 100644 index 0000000000..b270e297cf --- /dev/null +++ b/tests/PHPStan/Fixtures/Managers/ExportManager.php @@ -0,0 +1,34 @@ +config->string('exports.default'); + } +} diff --git a/tests/PHPStan/Fixtures/Managers/WrappedCreatorManager.php b/tests/PHPStan/Fixtures/Managers/WrappedCreatorManager.php new file mode 100644 index 0000000000..8e233d5794 --- /dev/null +++ b/tests/PHPStan/Fixtures/Managers/WrappedCreatorManager.php @@ -0,0 +1,30 @@ +exists(); + } + + /** + * Call a method of the builder's query class. + */ + public function active(UserBuilder $builder): void + { + $builder->onlyActive(); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Columns.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Columns.php new file mode 100644 index 0000000000..38accb5e98 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Columns.php @@ -0,0 +1,10 @@ +> */ + use HasBuilder; + + protected static string $builder = UserBuilder::class; + + /** + * Get the group's subgroups. + * + * @return HasMany + */ + public function subgroups(): HasMany + { + return $this->hasMany(Group::class, 'parent_id'); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Member.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Member.php new file mode 100644 index 0000000000..f76dd775e2 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Member.php @@ -0,0 +1,12 @@ +published(); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/ModelUsage.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/ModelUsage.php new file mode 100644 index 0000000000..62dd16dcfc --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/ModelUsage.php @@ -0,0 +1,19 @@ +subgroups()->exists(); + } + + /** + * Call a builder method through a relation. + */ + public function publishedSubgroups(Group $group): void + { + $group->subgroups()->published(); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Usage.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Usage.php new file mode 100644 index 0000000000..a6088b4808 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/Usage.php @@ -0,0 +1,24 @@ +nickname; + } + + /** + * Read a column the model casts. + */ + public function name(User $user): string + { + return $user->name; + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/User.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/User.php new file mode 100644 index 0000000000..0084b3c0d4 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/User.php @@ -0,0 +1,25 @@ +> */ + use HasBuilder; + + protected static string $builder = UserBuilder::class; + + /** + * Get the attributes that should be cast. + */ + protected function casts(): array + { + return [ + 'name' => 'string', + ]; + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserBuilder.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserBuilder.php new file mode 100644 index 0000000000..f216af2df8 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserBuilder.php @@ -0,0 +1,38 @@ + + */ +class UserBuilder extends Builder +{ + /** @var UserQuery */ + protected QueryBuilder $query; + + protected array $passthru = ['exists']; + + /** + * Get the underlying query builder instance. + */ + public function getQuery(): UserQuery + { + return $this->query; + } + + /** + * Constrain the query to published users. + */ + public function published(): static + { + return $this->whereNotNull('published_at'); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php new file mode 100644 index 0000000000..24f2210fb5 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/app/Models/UserQuery.php @@ -0,0 +1,18 @@ +where('active', true); + } +} diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/app.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/app.php new file mode 100644 index 0000000000..72856d7b08 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/app.php @@ -0,0 +1,7 @@ +create(); diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/autoload.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/autoload.php new file mode 100644 index 0000000000..b3a6460fd4 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/bootstrap/autoload.php @@ -0,0 +1,10 @@ +addPsr4('ModelPropertyDependency\Models\\', dirname(__DIR__) . '/app/Models'); +$loader->register(); diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/models.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/models.php new file mode 100644 index 0000000000..5115ea3c07 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/models.php @@ -0,0 +1,7 @@ + 'sqlite', +]; diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/schema.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/schema.php new file mode 100644 index 0000000000..5115ea3c07 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/config/schema.php @@ -0,0 +1,7 @@ + 'sqlite', +]; diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_01_000000_create_users_table.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_01_000000_create_users_table.php new file mode 100644 index 0000000000..fe6096a5ae --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_01_000000_create_users_table.php @@ -0,0 +1,29 @@ +id(); + $table->string(Columns::NAME); + }); + } +}; diff --git a/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_02_000000_create_flags_table.php b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_02_000000_create_flags_table.php new file mode 100644 index 0000000000..4111211b50 --- /dev/null +++ b/tests/PHPStan/Fixtures/ModelPropertyDependency/application/database/migrations/2026_01_02_000000_create_flags_table.php @@ -0,0 +1,35 @@ +id(); + $table->string(FlagColumns::NAME); + }); + + if (Schema::hasColumn('flags', 'nickname')) { + Schema::table('users', function (Blueprint $table) { + $table->string('nickname'); + }); + } + } +}; diff --git a/tests/PHPStan/Fixtures/OverridesConfig.php b/tests/PHPStan/Fixtures/OverridesConfig.php new file mode 100644 index 0000000000..3ab8e9e4d4 --- /dev/null +++ b/tests/PHPStan/Fixtures/OverridesConfig.php @@ -0,0 +1,30 @@ + $values + * @param callable(): TResult $callback + * @return TResult + */ + private function withConfig(array $values, callable $callback): mixed + { + $items = config()->all(); + + try { + config($values); + + return $callback(); + } finally { + config()->replaceItems($items); + } + } +} diff --git a/tests/PHPStan/Fixtures/QueueJobDependency/application/app/Jobs/AttributeJob.php b/tests/PHPStan/Fixtures/QueueJobDependency/application/app/Jobs/AttributeJob.php new file mode 100644 index 0000000000..55292be5a9 --- /dev/null +++ b/tests/PHPStan/Fixtures/QueueJobDependency/application/app/Jobs/AttributeJob.php @@ -0,0 +1,17 @@ +create(); diff --git a/tests/PHPStan/Fixtures/QueueJobDependency/application/bootstrap/autoload.php b/tests/PHPStan/Fixtures/QueueJobDependency/application/bootstrap/autoload.php new file mode 100644 index 0000000000..ad4ffb19d8 --- /dev/null +++ b/tests/PHPStan/Fixtures/QueueJobDependency/application/bootstrap/autoload.php @@ -0,0 +1,10 @@ +addPsr4('QueueJobDependency\Jobs\\', dirname(__DIR__) . '/app/Jobs'); +$loader->register(); diff --git a/tests/PHPStan/Fixtures/QueueJobDependency/application/config/database.php b/tests/PHPStan/Fixtures/QueueJobDependency/application/config/database.php new file mode 100644 index 0000000000..f296d47587 --- /dev/null +++ b/tests/PHPStan/Fixtures/QueueJobDependency/application/config/database.php @@ -0,0 +1,7 @@ + 'pgsql', +]; diff --git a/tests/PHPStan/Fixtures/QueueJobDependency/application/config/queue.php b/tests/PHPStan/Fixtures/QueueJobDependency/application/config/queue.php new file mode 100644 index 0000000000..544916435b --- /dev/null +++ b/tests/PHPStan/Fixtures/QueueJobDependency/application/config/queue.php @@ -0,0 +1,12 @@ + 'before-commit', + + 'connections' => [ + 'before-commit' => ['driver' => 'redis', 'connection' => 'default', 'queue' => 'default', 'retry_after' => 90, 'after_commit' => false], + 'database-mysql' => ['driver' => 'database', 'connection' => 'mysql', 'table' => 'jobs', 'queue' => 'default', 'retry_after' => 90, 'after_commit' => false], + ], +]; diff --git a/tests/PHPStan/Fixtures/ReadsSchemaFacts.php b/tests/PHPStan/Fixtures/ReadsSchemaFacts.php new file mode 100644 index 0000000000..8e98608ab8 --- /dev/null +++ b/tests/PHPStan/Fixtures/ReadsSchemaFacts.php @@ -0,0 +1,87 @@ + $connections + */ + protected function findSchema(array $connections, ?string $connection = null): ?SchemaConnection + { + return $connections[$this->connectionConfig($connection)->owner] ?? null; + } + + /** + * Get the facts of a table as a model on the connection looks it up, or null when it is not known. + * + * @param array $connections + */ + protected function findTable(array $connections, string $table, ?string $connection = null): ?SchemaTable + { + $config = $this->connectionConfig($connection); + $schema = $connections[$config->owner] ?? null; + [$tableSchema, $name] = $config->physicalTable($table); + $key = $schema?->findTableKey($tableSchema, $name, $config->defaultSchema, $config->searchPath); + + return $schema !== null && is_string($key) ? $schema->tables[$key] : null; + } + + /** + * Get a table's columns as their read types (null when unknown) and nullability, or null when the table is not known. + * + * @param array $connections + * + * @return null|array + */ + protected function describeColumns(array $connections, string $table, ?string $connection = null): ?array + { + $config = $this->connectionConfig($connection); + $schema = $this->findSchema($connections, $connection); + $tableFacts = $this->findTable($connections, $table, $connection); + + if ($schema === null || $tableFacts === null) { + return null; + } + + $converter = self::getContainer()->getByType(DataTypeToPhpTypeConverter::class); + $columns = []; + + foreach ($tableFacts->columns as $column) { + $columns[$column->name] = [ + $column->type === null + ? null + : $converter->convert($schema->driver, $column->type, $column->options, $schema, $tableFacts->searchPath ?? $config->searchPath)['readable']->describe(VerbosityLevel::precise()), + $column->nullable, + ]; + } + + return $columns; + } + + /** + * Get the facts of a connection, or of the default connection when no name is given, failing when they cannot be resolved. + */ + private function connectionConfig(?string $connection): ConnectionConfig + { + $resolver = self::getContainer()->getByType(ConnectionConfigResolver::class); + $config = $resolver->connection($connection ?? (string) $resolver->getDefaultConnectionName()); + + $this->assertNotNull($config); + + return $config; + } +} diff --git a/tests/PHPStan/Fixtures/Reflection/MixinHost.php b/tests/PHPStan/Fixtures/Reflection/MixinHost.php new file mode 100644 index 0000000000..8dda7f5dc8 --- /dev/null +++ b/tests/PHPStan/Fixtures/Reflection/MixinHost.php @@ -0,0 +1,15 @@ + $arguments + */ + public function lookup(Translator $translator, string $locale, bool $french, bool $fallback, array $arguments): void + { + __('lookups.present'); + __('lookups.missing'); + __('lookups.identical'); + __('unknown.missing'); + + __('lookups.missing', [], 'fr'); + __('lookups.present', [], 'fr'); + Lang::get('lookups.present', [], 'fr', false); + Lang::get('lookups.french', [], 'fr', false); + Lang::get('lookups.present', [], 'fr', $fallback); + __('lookups.missing', [], $locale); + __('lookups.missing', [], $french ? 'fr' : 'en'); + __('lookups.missing', [], $french ? 'fr' : null); + + trans('lookups.missing', [], 'fr'); + trans_choice('lookups.missing', 1, [], 'fr'); + Lang::choice('lookups.missing', 1, [], 'fr'); + $translator->get('lookups.missing', locale: 'fr'); + $translator->choice('lookups.missing', 1, locale: 'fr'); + __(locale: 'fr', key: 'lookups.missing'); + __('lookups.missing', ...$arguments); + + trans_choice('lookups.apples', 1, [], 'fr'); + __('lookups.apples', [], 'fr'); + } +} diff --git a/tests/PHPStan/Fixtures/Rules/lang/en.json b/tests/PHPStan/Fixtures/Rules/lang/en.json new file mode 100644 index 0000000000..cab1646e21 --- /dev/null +++ b/tests/PHPStan/Fixtures/Rules/lang/en.json @@ -0,0 +1,4 @@ +{ + "lookups": "Lookups", + "lookups.apples": "Apples" +} diff --git a/tests/PHPStan/Fixtures/Rules/lang/en/lookups.php b/tests/PHPStan/Fixtures/Rules/lang/en/lookups.php new file mode 100644 index 0000000000..bab0614d0c --- /dev/null +++ b/tests/PHPStan/Fixtures/Rules/lang/en/lookups.php @@ -0,0 +1,8 @@ + 'Present', + 'identical' => 'lookups.identical', +]; diff --git a/tests/PHPStan/Fixtures/Rules/lang/es/lookups.php b/tests/PHPStan/Fixtures/Rules/lang/es/lookups.php new file mode 100644 index 0000000000..b1a32dd3a2 --- /dev/null +++ b/tests/PHPStan/Fixtures/Rules/lang/es/lookups.php @@ -0,0 +1,7 @@ + 'lookups.spanish', +]; diff --git a/tests/PHPStan/Fixtures/Rules/lang/fr/lookups.php b/tests/PHPStan/Fixtures/Rules/lang/fr/lookups.php new file mode 100644 index 0000000000..48002ebfcd --- /dev/null +++ b/tests/PHPStan/Fixtures/Rules/lang/fr/lookups.php @@ -0,0 +1,7 @@ + 'Français', +]; diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/Package/EnglishGreeter.php b/tests/PHPStan/Fixtures/RuntimeInputs/Package/EnglishGreeter.php new file mode 100644 index 0000000000..b2ba6ce367 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/Package/EnglishGreeter.php @@ -0,0 +1,16 @@ +bonjour(); + } + + /** + * Get the French greeting. + */ + public function bonjour(): string + { + return 'Bonjour'; + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/Package/Greeter.php b/tests/PHPStan/Fixtures/RuntimeInputs/Package/Greeter.php new file mode 100644 index 0000000000..b509a32b87 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/Package/Greeter.php @@ -0,0 +1,13 @@ +app->make('config'); + $french = $config->get('package.greeter') === 'french' + || $config->get('database.connections.mysql.database') === 'french' + || $config->get('database.default') === 'sqlite'; + + $this->app->bind(Greeter::class, $french ? FrenchGreeter::class : EnglishGreeter::class); + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/Package/Usage.php b/tests/PHPStan/Fixtures/RuntimeInputs/Package/Usage.php new file mode 100644 index 0000000000..ece91b6caf --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/Package/Usage.php @@ -0,0 +1,13 @@ +bonjour(); + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/CounterController.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/CounterController.php new file mode 100644 index 0000000000..8b2e97f5a5 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/CounterController.php @@ -0,0 +1,15 @@ +hits; + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Exports/BoundReportManager.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Exports/BoundReportManager.php new file mode 100644 index 0000000000..2779fa5804 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Exports/BoundReportManager.php @@ -0,0 +1,16 @@ +bonjour(); + } + + /** + * Get the French greeting. + */ + public function bonjour(): string + { + return 'Bonjour'; + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Greeters/Greeter.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Greeters/Greeter.php new file mode 100644 index 0000000000..acd53a94c7 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Greeters/Greeter.php @@ -0,0 +1,13 @@ +view('mail-page'); + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Models/Comment.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Models/Comment.php new file mode 100644 index 0000000000..9a7b0e5243 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Models/Comment.php @@ -0,0 +1,21 @@ + + */ + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Models/Post.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Models/Post.php new file mode 100644 index 0000000000..4823bfb2c3 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Models/Post.php @@ -0,0 +1,21 @@ + + */ + public function comments(): HasMany + { + return $this->hasMany(Comment::class); + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Models/Video.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Models/Video.php new file mode 100644 index 0000000000..5479c807c8 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Models/Video.php @@ -0,0 +1,21 @@ + + */ + public function comments(): BelongsToMany + { + return $this->belongsToMany(Comment::class); + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Providers/FixtureServiceProvider.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Providers/FixtureServiceProvider.php new file mode 100644 index 0000000000..9b35766ed8 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Providers/FixtureServiceProvider.php @@ -0,0 +1,41 @@ +app); + + // Bound lazily, so the root class loads during analysis, after the boot. + $this->app->bind('fixture.root', static fn (): FacadeRoot => new FacadeRoot); + $this->app->singleton(ReportManager::class, BoundReportManager::class); + } + + /** + * Register the fixture macros, morph map and view namespace. + */ + public function boot(): void + { + Str::macro('fixtureMacro', fn (): int => 1); + Str::mixin(new StrMixin); + Relation::morphMap(['post' => Post::class]); + $this->loadViewsFrom(dirname(__DIR__, 2) . '/resources/fixture-views', 'fixture'); + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Registrar.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Registrar.php new file mode 100644 index 0000000000..96c5f6f7e2 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Registrar.php @@ -0,0 +1,21 @@ +bind(Greeter::class, $app->make('config')->get('fixture.greeter') === 'french' ? FrenchGreeter::class : EnglishGreeter::class); + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/ReportUsage.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/ReportUsage.php new file mode 100644 index 0000000000..7627c5ecb8 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/ReportUsage.php @@ -0,0 +1,15 @@ +export(); + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/StrMixin.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/StrMixin.php new file mode 100644 index 0000000000..f8e08eae30 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/StrMixin.php @@ -0,0 +1,18 @@ + 1; + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Usage.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Usage.php new file mode 100644 index 0000000000..90ae814712 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/Usage.php @@ -0,0 +1,48 @@ +bonjour(); + } + + public function facade(): string + { + return Fixture::shout(); + } + + public function export(): int + { + return app(ExportManager::class)->export(); + } + + /** + * @param Collection $comments + */ + public function morph(Collection $comments): void + { + $comments->loadMorph('commentable', ['post' => ['comments' => fn (HasMany $relation) => $relation]]); + } +} diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/app/ViewUsage.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/ViewUsage.php new file mode 100644 index 0000000000..7366221cb8 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/app/ViewUsage.php @@ -0,0 +1,18 @@ +withRouting(web: __DIR__ . '/../routes/web.php') + ->withProviders([FixtureServiceProvider::class]) + ->create(); diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/bootstrap/autoload.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/bootstrap/autoload.php new file mode 100644 index 0000000000..e65c3353af --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/bootstrap/autoload.php @@ -0,0 +1,10 @@ +addPsr4('RuntimeInputs\\', dirname(__DIR__) . '/app'); +$loader->register(); diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/config/fixture.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/config/fixture.php new file mode 100644 index 0000000000..5d297bf929 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/config/fixture.php @@ -0,0 +1,7 @@ + env('FIXTURE_GREETER', 'english'), +]; diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/resources/views/welcome.blade.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/resources/views/welcome.blade.php new file mode 100644 index 0000000000..68002cd17b --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/resources/views/welcome.blade.php @@ -0,0 +1 @@ +

Welcome

diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/application/routes/web.php b/tests/PHPStan/Fixtures/RuntimeInputs/application/routes/web.php new file mode 100644 index 0000000000..fbdec7b263 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/application/routes/web.php @@ -0,0 +1,7 @@ + 'ok'); diff --git a/tests/PHPStan/Fixtures/SchemaAggregator/Author.php b/tests/PHPStan/Fixtures/SchemaAggregator/Author.php new file mode 100644 index 0000000000..78b7a8ce4c --- /dev/null +++ b/tests/PHPStan/Fixtures/SchemaAggregator/Author.php @@ -0,0 +1,9 @@ +connection = config('schema_aggregator.connection'); + } +} diff --git a/tests/PHPStan/Fixtures/SchemaAggregator/Constants.php b/tests/PHPStan/Fixtures/SchemaAggregator/Constants.php new file mode 100644 index 0000000000..33774d2a0a --- /dev/null +++ b/tests/PHPStan/Fixtures/SchemaAggregator/Constants.php @@ -0,0 +1,17 @@ + $table->string('name')); + } +} diff --git a/tests/PHPStan/Fixtures/SchemaAggregator/DefinesConnection.php b/tests/PHPStan/Fixtures/SchemaAggregator/DefinesConnection.php new file mode 100644 index 0000000000..c8f0429b7e --- /dev/null +++ b/tests/PHPStan/Fixtures/SchemaAggregator/DefinesConnection.php @@ -0,0 +1,10 @@ +createParts(); + $this->createLedgers(); + } + + /** + * Create the layer table this class names. + */ + protected function createLayer(): void + { + Schema::create('base_layers', fn (Blueprint $table) => $table->string('name')); + } + + /** + * Create the ledgers table, which up() runs whatever private method of that name a migration declares. + */ + private function createLedgers(): void + { + Schema::create('ledgers', fn (Blueprint $table) => $table->string('name')); + } + + /** + * Create the widgets table. + */ + protected function createWidgets(): void + { + Schema::create('widgets', fn (Blueprint $table) => $table->string('name')); + } + + /** + * Create the gadgets table, then call itself once more. + */ + protected function createGadgets(bool $again = false): void + { + if ($again) { + return; + } + + Schema::create('gadgets', fn (Blueprint $table) => $table->string('name')); + + $this->createGadgets(true); + } + + /** + * Create the gizmos table on the migration's connection through a stored builder. + */ + protected function createGizmos(): void + { + $schema = Schema::connection($this->connection); + + $schema->create('gizmos', fn (Blueprint $table) => $table->string('name')); + } +} diff --git a/tests/PHPStan/Fixtures/SchemaAggregator/NamesConnection.php b/tests/PHPStan/Fixtures/SchemaAggregator/NamesConnection.php new file mode 100644 index 0000000000..10479bba22 --- /dev/null +++ b/tests/PHPStan/Fixtures/SchemaAggregator/NamesConnection.php @@ -0,0 +1,16 @@ +connection = $connection; + + return $this; + } +} diff --git a/tests/PHPStan/Fixtures/SchemaAggregator/TypedConstants.php b/tests/PHPStan/Fixtures/SchemaAggregator/TypedConstants.php new file mode 100644 index 0000000000..1942cb6b77 --- /dev/null +++ b/tests/PHPStan/Fixtures/SchemaAggregator/TypedConstants.php @@ -0,0 +1,12 @@ +request = $request; + } +} diff --git a/tests/PHPStan/Fixtures/WorkerStateDependency/application/app/Usage.php b/tests/PHPStan/Fixtures/WorkerStateDependency/application/app/Usage.php new file mode 100644 index 0000000000..dc5dd36255 --- /dev/null +++ b/tests/PHPStan/Fixtures/WorkerStateDependency/application/app/Usage.php @@ -0,0 +1,13 @@ +create(); diff --git a/tests/PHPStan/Fixtures/WorkerStateDependency/application/bootstrap/autoload.php b/tests/PHPStan/Fixtures/WorkerStateDependency/application/bootstrap/autoload.php new file mode 100644 index 0000000000..f83b4c5d18 --- /dev/null +++ b/tests/PHPStan/Fixtures/WorkerStateDependency/application/bootstrap/autoload.php @@ -0,0 +1,10 @@ +addPsr4('WorkerStateDependency\\', dirname(__DIR__) . '/app'); +$loader->register(); diff --git a/tests/PHPStan/Integration/BleedingEdgeIntegrationTest.php b/tests/PHPStan/Integration/BleedingEdgeIntegrationTest.php new file mode 100644 index 0000000000..875bc0ac4a --- /dev/null +++ b/tests/PHPStan/Integration/BleedingEdgeIntegrationTest.php @@ -0,0 +1,34 @@ + */ + public static function dataIntegrationTests(): iterable + { + yield [ + __DIR__ . '/data/bleeding-edge/collections.php', + [ + 27 => ['Parameter #1 ...$values of method Hypervel\Support\Collection::push() expects int, string given.'], + ], + ]; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + // PHPStan's own bleedingEdge.neon includes the config through a phar alias that only exists when the phar is executed. + $pharRoot = dirname((string) (new ReflectionClass(PHPStanTestCase::class))->getFileName(), 3); + + return array_merge(parent::getAdditionalConfigFiles(), [$pharRoot . '/conf/bleedingEdge.neon']); + } +} diff --git a/tests/PHPStan/Integration/IntegrationTest.php b/tests/PHPStan/Integration/IntegrationTest.php new file mode 100644 index 0000000000..0f4a57f8ab --- /dev/null +++ b/tests/PHPStan/Integration/IntegrationTest.php @@ -0,0 +1,679 @@ +> $expectedErrors + * + * @throws Throwable + */ + #[DataProvider('dataIntegrationTests')] + public function testIntegration(string $file, ?array $expectedErrors = null): void + { + $errors = $this->runAnalyse($file); + + if ($expectedErrors === null) { + $this->assertNoErrors($errors); + } else { + $this->assertSameErrorMessages($file, $expectedErrors, $errors); + } + } + + /** @return iterable>}> */ + public static function dataIntegrationTests(): iterable + { + self::getContainer(); + + yield [__DIR__ . '/data/bug-2074.php']; + + // Hypervel: the request body parameters are natively typed as Laravel documents them, so null and other + // objects throw a TypeError instead of reaching Guzzle. + yield [ + __DIR__ . '/data/bug-2426.php', + [ + 9 => ['Parameter #2 $data of static method Hypervel\Http\Client\PendingRequest::post() expects array|Hypervel\Contracts\Support\Arrayable|JsonSerializable, null given.'], + 10 => ['Parameter #2 $data of static method Hypervel\Http\Client\PendingRequest::post() expects array|Hypervel\Contracts\Support\Arrayable|JsonSerializable, object{}&stdClass given.'], + 11 => ['Parameter #2 $data of static method Hypervel\Http\Client\PendingRequest::patch() expects array|Hypervel\Contracts\Support\Arrayable|JsonSerializable, null given.'], + 12 => ['Parameter #2 $data of static method Hypervel\Http\Client\PendingRequest::put() expects array|Hypervel\Contracts\Support\Arrayable|JsonSerializable, null given.'], + 13 => ['Parameter #2 $data of static method Hypervel\Http\Client\PendingRequest::delete() expects array|Hypervel\Contracts\Support\Arrayable|JsonSerializable, null given.'], + ], + ]; + + yield [__DIR__ . '/data/bug-2431.php']; + + // Hypervel: User's public email() accessor, whose Attribute has no generic types, reads as mixed, because + // Eloquent calls it. Upstream ignores it. + yield 'bug-2516' => [ + __DIR__ . '/data/bug-2516.php', + [ + 14 => ['Method Bug2516\Stations::all() should return Hypervel\Support\Collection but returns Hypervel\Support\Collection.'], + ], + ]; + + // Hypervel: a model that cannot be instantiated has no known schema, so its properties are not reported missing. + yield 'bug-2545' => [ + __DIR__ . '/data/bug-2545.php', + [ + 7 => ['Class Bug2545\CalendarEvent implements unknown interface Bug2545\MissingInterface.'], + ], + ]; + + yield 'bug-2582' => [__DIR__ . '/data/bug-2582.php']; + + // Hypervel: a date attribute accepts what HasAttributes::fromDateTime() converts. + yield 'bug-1744' => [ + __DIR__ . '/data/bug-1744.php', + [ + 13 => ['Property App\ModelWithDateColumnCasts::$created_at (DateTimeInterface|float|int|string|null) does not accept array.'], + ], + ]; + + yield [__DIR__ . '/data/bug-final_model_query.php']; + yield [__DIR__ . '/data/test-case-extension.php']; + yield [__DIR__ . '/data/model-builder.php']; + + yield 'eloquent-builder-paginate' => [ + __DIR__ . '/data/eloquent-builder-paginate.php', + [ + 34 => ['Parameter #1 $perPage of method Hypervel\Database\Eloquent\Builder::paginate() expects (Closure(int): (int|null))|int|null, Closure(string): 15 given.'], + 35 => ['Parameter #1 $perPage of method Hypervel\Database\Eloquent\Builder::paginate() expects (Closure(int): (int|null))|int|null, Closure(int): \'15\' given.'], + ], + ]; + + yield 'relation-cursor-paginate' => [__DIR__ . '/data/relation-cursor-paginate.php']; + + // Hypervel: Model::newEloquentBuilder() types $query natively, so an untyped override has no PHPDoc type to inherit. + yield [ + __DIR__ . '/data/custom-builder-return-this.php', + [ + 14 => ['Method CustomBuilderReturnThis\Post::newEloquentBuilder() has parameter $query with no type specified.'], + ], + ]; + + yield 'framework-docblock-types' => [__DIR__ . '/data/framework-docblock-types.php']; + + // Hypervel: eager-loading constraints accept any callable, as Builder::combineConstraints() calls them, and + // each key's callback is checked against that key's own relation instead of a union of the keys' relations. + yield 'model-with-relations' => [ + __DIR__ . '/data/model-with-relations.php', + [ + 22 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string, group: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsTo): mixed)|string}|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void, group: Closure(Hypervel\Database\Eloquent\Relations\HasMany): void} given.'], + 27 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array): mixed)|string>|string, non-empty-array given.'], + 28 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void} given.'], + 29 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(int): void} given.'], + 30 => ['Relation \'missing\' is not found in App\User model.'], + 31 => ['Parameter #1 $relations of static method Hypervel\Database\Eloquent\Model::with() expects array): mixed)|string>|string, non-empty-array given.'], + 34 => ['Parameter #1 $relation of function ModelWithRelationsIntegration\acceptsAccounts expects Hypervel\Database\Eloquent\Relations\HasMany, Hypervel\Database\Eloquent\Relations\BelongsTo given.'], + ], + ]; + + // Hypervel: each key's callback is checked against that key's own relation, and eager-loading constraints + // accept any callable. + yield 'eager-loading-callbacks' => [ + __DIR__ . '/data/eager-loading-callbacks.php', + [ + 32 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Builder::with() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void} given.'], + 33 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Builder::withOnly() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(int): void} given.'], + 34 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Model::load() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void} given.'], + 35 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Model::loadMissing() expects array{group: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsTo): mixed)|string}|string, array{group: Closure(Hypervel\Database\Eloquent\Relations\HasMany): void} given.'], + 36 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Collection::load() expects array{accounts: array|(callable(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string, array{accounts: Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void} given.'], + 37 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Collection::loadMissing() expects array{group: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsTo): mixed)|string}|string, array{group: Closure(Hypervel\Database\Eloquent\Relations\HasMany): void} given.'], + 38 => ['Relation \'missing\' is not found in App\User model.'], + 39 => ['Parameter #2 $callback of method Hypervel\Database\Eloquent\Builder::with() expects (Closure(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string|null, Closure(Hypervel\Database\Eloquent\Relations\BelongsTo): void given.'], + ], + ]; + + // Hypervel: per-key aggregate and morph map callbacks, and callbacks that may receive either of two relations. + yield 'eager-load-maps' => [ + __DIR__ . '/data/eager-load-maps.php', + [ + 36 => ['Parameter #1 $relations of method Hypervel\Database\Eloquent\Collection::loadSum() expects array{\'accounts as total\': array{}|(callable(Hypervel\Database\Eloquent\Builder): mixed)|string}|string, array{\'accounts as total\': Closure(App\PostBuilder): (App\PostBuilder&Hypervel\Database\Eloquent\Builder)} given.'], + 37 => ['Parameter #2 $relations of method Hypervel\Database\Eloquent\Collection::loadMorph() expects array{post: array{users: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsToMany): mixed)|string}|string}, array{post: array{users: Closure(Hypervel\Database\Eloquent\Relations\HasMany): Hypervel\Database\Eloquent\Relations\HasMany}} given.'], + 38 => ['Parameter #1 $with of method Hypervel\Database\Eloquent\Relations\MorphTo::morphWith() expects array{post: array{users: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsToMany): mixed)|string}|string}, array{post: array{users: Closure(Hypervel\Database\Eloquent\Relations\HasMany): Hypervel\Database\Eloquent\Relations\HasMany}} given.'], + 39 => ['Parameter #1 $callbacks of method Hypervel\Database\Eloquent\Relations\MorphTo::constrain() expects array{App\User: callable(Hypervel\Database\Eloquent\Builder): mixed}, array{App\User: Closure(App\PostBuilder): (App\PostBuilder&Hypervel\Database\Eloquent\Builder)} given.'], + 40 => ['Parameter #2 $relations of method Hypervel\Database\Eloquent\Collection::loadMorph() expects array{App\Group: array{users: array|(callable(Hypervel\Database\Eloquent\Relations\BelongsToMany|Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string}|string}, array{App\Group: array{users: Closure(Hypervel\Database\Eloquent\Relations\HasMany): Hypervel\Database\Eloquent\Relations\HasMany}} given.'], + 41 => ['Parameter #2 $callback of method Hypervel\Database\Eloquent\Builder::with() expects (Closure(Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string|null, Closure(Hypervel\Database\Eloquent\Relations\HasMany): Hypervel\Database\Eloquent\Relations\HasMany given.'], + ], + ]; + + // Hypervel: a where column may also be a builder or relation subquery, as the query builder runs it. + yield 'eloquent-where' => [ + __DIR__ . '/data/eloquent-where.php', + [ + 44 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Eloquent\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, Closure(Hypervel\Database\Query\Builder): Hypervel\Database\Query\Builder given.'], + 45 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::firstWhere() expects array|(Closure(Hypervel\Database\Eloquent\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, Closure(Hypervel\Database\Query\Builder): Hypervel\Database\Query\Builder given.'], + 46 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::whereNot() expects array|(Closure(Hypervel\Database\Eloquent\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, Closure(Hypervel\Database\Query\Builder): Hypervel\Database\Query\Builder given.'], + 47 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhereNot() expects array|(Closure(Hypervel\Database\Eloquent\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, Closure(Hypervel\Database\Query\Builder): Hypervel\Database\Query\Builder given.'], + 49 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, static-Closure(Hypervel\Database\Eloquent\Builder): Hypervel\Database\Eloquent\Builder given.'], + 50 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::firstWhere() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, static-Closure(Hypervel\Database\Eloquent\Builder): Hypervel\Database\Eloquent\Builder given.'], + 51 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::whereNot() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, static-Closure(Hypervel\Database\Eloquent\Builder): Hypervel\Database\Eloquent\Builder given.'], + 52 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhereNot() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, static-Closure(Hypervel\Database\Eloquent\Builder): Hypervel\Database\Eloquent\Builder given.'], + 53 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhere() expects array|(Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, Closure(int): void given.'], + ], + ]; + + // Hypervel: forwarded calls name the model. + yield [ + __DIR__ . '/data/relationship-query-callbacks.php', + [ + 69 => ['Parameter #3 $callback of static method App\Comment::whereHasMorph() expects (Closure(App\PostBuilder, string): mixed)|null, Closure(int): void given.'], + 70 => ['Parameter #3 $callback of static method App\Comment::whereHasMorph() expects (Closure(App\PostBuilder, string): mixed)|null, Closure(Hypervel\Database\Eloquent\Builder, int): void given.'], + 71 => ['Parameter #3 $callback of static method App\Comment::whereHasMorph() expects (Closure(Hypervel\Database\Eloquent\Builder, string): mixed)|null, Closure(App\PostBuilder): void given.'], + 91 => ['Parameter #2 $callback of static method App\User::withWhereHas() expects (Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany): mixed)|null, Closure(Hypervel\Database\Eloquent\Builder): void given.'], + 92 => ['Parameter #2 $callback of method Hypervel\Database\Eloquent\Builder::withWhereHas() expects (Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany): mixed)|null, Closure(Hypervel\Database\Eloquent\Relations\Relation): void given.'], + 126 => ['Parameter #2 $column of static method App\User::whereRelation() expects array|(Closure(Hypervel\Database\Eloquent\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|string, Closure(App\PostBuilder): void given.'], + 127 => ['Parameter #3 $column of static method App\Comment::whereMorphRelation() expects array|(Closure(App\PostBuilder): mixed)|Hypervel\Contracts\Database\Query\Expression|string, Closure(int): void given.'], + 128 => ['Parameter #3 $column of static method App\Comment::whereMorphRelation() expects array|(Closure(App\PostBuilder): mixed)|Hypervel\Contracts\Database\Query\Expression|string, Closure(App\PostBuilder, string): void given.'], + 129 => ['Parameter #2 $column of static method App\User::withWhereRelation() expects array|(Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany): mixed)|Hypervel\Contracts\Database\Query\Expression|string, Closure(Hypervel\Database\Eloquent\Builder): void given.'], + 130 => ['Parameter #2 $callback of method Hypervel\Database\Eloquent\Builder::with() expects (Closure(Hypervel\Database\Eloquent\Relations\HasMany): mixed)|string|null, Closure(Hypervel\Database\Eloquent\Builder): void given.'], + 142 => ['Parameter #3 $callback of static method App\Comment::whereHasMorph() expects (Closure(App\PostBuilder, string): mixed)|null, Closure(Hypervel\Database\Eloquent\Builder): void given.'], + ], + ]; + + // Hypervel: User's public email() accessor reads as mixed; date attributes read as CarbonInterface; SQLite + // has no unsigned columns; AsEnumCollection also stores the enum's backing values. + yield [ + __DIR__ . '/data/model-properties.php', + [ + 71 => ['Expected type string, actual: mixed'], + 74 => ['Function testDateCast() should return Carbon\Carbon|null but returns Carbon\CarbonInterface|null.'], + 100 => ['Property App\User::$decimal (float|int|numeric-string) does not accept \'foo\'.'], + 101 => ['Property App\User::$decimal (float|int|numeric-string) does not accept array{}.'], + 110 => ['Property App\User::$nullable_decimal (float|int|numeric-string|null) does not accept \'foo\'.'], + 111 => ['Property App\User::$nullable_decimal (float|int|numeric-string|null) does not accept array{}.'], + 118 => ['Property App\User::$enum_collection (iterable|null) does not accept array.'], + ], + ]; + + // Hypervel: enum casts accept the values Eloquent resolves to a case, a caster's set() decides whether + // it accepts null, and an attribute without a known column may be set to null. + yield 'model-cast-writes' => [ + __DIR__ . '/data/model-cast-writes.php', + [ + 18 => ['Property ModelCastWrites\EnumCastModel::$int (int|ModelCastWrites\IntStatus|numeric-string) does not accept \'one\'.'], + 24 => ['Property ModelCastWrites\EnumCastModel::$integer (ModelCastWrites\PureStatus|string) does not accept int.'], + 31 => ['Property ModelCastWrites\CasterModel::$nullable_collection (ModelCastWrites\Label) does not accept null.'], + 45 => ['Property ModelCastWrites\SoftDeleteCastModel::$deleted_at (ModelCastWrites\Label) does not accept DateTimeImmutable.'], + ], + ]; + + // Hypervel: also covers the has* sequence and trashed() value forms, trashed() on a model that does not + // soft delete, and a has* name whose method returns no relation. + yield [ + __DIR__ . '/data/model-factories.php', + [ + 16 => ['Call to an undefined method Database\Factories\Post\PostFactory::trashed().'], + 17 => ['Call to an undefined method Database\Factories\UserFactory::hasGetTable().'], + ], + ]; + + yield [__DIR__ . '/data/blade-view.php']; + + yield [ + __DIR__ . '/data/view-string-content.php', + [ + 10 => ['Parameter $view of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 11 => ['Parameter $html of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 12 => ['Parameter $text of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 13 => ['Parameter $markdown of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 14 => [ + 'Parameter #1 $view of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.', + 'Parameter #2 $html of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.', + 'Parameter #3 $text of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.', + 'Parameter #4 $markdown of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.', + ], + 15 => ['Parameter $view of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 28 => [ + 'Parameter $html of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string|null given.', + 'Parameter $markdown of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string|null given.', + 'Parameter $text of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string|null given.', + 'Parameter $view of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string|null given.', + ], + 29 => ['Parameter $view of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + 30 => ['Parameter $text of class Hypervel\Mail\Mailables\Content constructor expects view-string|null, string given.'], + ], + ]; + + yield [ + __DIR__ . '/data/view-string-signatures.php', + [ + 17 => ['Parameter #1 $view of method Hypervel\Contracts\View\Factory::make() expects view-string, string given.'], + 23 => ['Parameter #2 $view of method Hypervel\Routing\Router::view() expects view-string, string given.'], + 29 => ['Parameter #1 $view of method Hypervel\Notifications\Messages\MailMessage::view() expects array|view-string, string given.'], + 31 => ['Parameter #1 $view of method Hypervel\Notifications\Messages\MailMessage::markdown() expects view-string, string given.'], + 38 => ['Parameter #1 $value of method Hypervel\Testing\TestResponse::assertViewIs() expects view-string, string given.'], + 52 => ['Parameter #1 $view of method ViewStringSignatures\UsesInteractsWithViews::view() expects view-string, string given.'], + 59 => ['Parameter #1 $view of static method Hypervel\View\Factory::make() expects view-string, string given.'], + 65 => ['Parameter #2 $view of static method Hypervel\Routing\Router::view() expects view-string, string given.'], + 71 => ['Parameter #1 $textView of method Hypervel\Notifications\Messages\MailMessage::text() expects view-string, string given.'], + 77 => ['Parameter #1 $textView of method Hypervel\Mail\Mailable::text() expects view-string, string given.'], + ], + ]; + + // Hypervel: retry() passes its callbacks any Throwable, as Laravel's does. + yield [ + __DIR__ . '/data/helpers.php', + [ + 14 => ['Parameter #3 $sleepMilliseconds of function retry expects Carbon\CarbonInterval|(Closure(int, Throwable): (Carbon\CarbonInterval|float|int))|float|int, Closure(int, Exception): 0 given.'], + 16 => ['Parameter #4 $when of function retry expects (callable(Throwable): bool)|null, Closure(Exception): true given.'], + ], + ]; + yield [__DIR__ . '/data/facades.php']; + + // Hypervel: in the model-property entries, calls forwarded through a model or relation name it as the + // declaring class. Here, a where column may also be a subquery, a key that is not a literal may name a + // property, and get() takes the array or string Laravel documents. + yield [ + __DIR__ . '/data/model-property-builder.php', + [ + 15 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::firstWhere() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 16 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::firstWhere() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'id\'|\'unionNotExisting\' given.'], + 17 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 19 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 20 => ['Parameter #1 $column of method App\User::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 25 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhere() expects array|(Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 26 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhere() expects array|(Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'foo\' given.'], + 27 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::orWhere() expects array|(Closure(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, array{foo: \'foo\'} given.'], + 30 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::value() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.'], + 35 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Builder::first() expects \'*\'|array|model property of App\User, array{\'foo\', \'bar\'} given.'], + 36 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Builder::first() expects \'*\'|array|model property of App\User, \'foo\' given.'], + 39 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\User, \'roles.foo\' given.'], + 41 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Builder::get() expects \'*\'|array|model property of App\User, Hypervel\Contracts\Database\Query\Expression given.'], + 44 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::createQuietly() expects array, array given.'], + 50 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\Account, \'foo\' given.'], + 54 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\Account, \'foo\' given.'], + ], + ]; + + // Hypervel: findSole() and findOr() take the column array or string Laravel documents. + yield 'find-sole-find-or' => [ + __DIR__ . '/data/model-property-find-sole-find-or.php', + [ + 6 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Builder::findSole() expects \'*\'|array|model property of App\User, array{\'foo\'} given.'], + 7 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Builder::findSole() expects \'*\'|array|model property of App\User, \'foo\' given.'], + 10 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Builder::findSole() expects \'*\'|array|model property of App\User, Hypervel\Contracts\Database\Query\Expression given.'], + 11 => ['Parameter #2 $columns of static method App\User::findSole() expects \'*\'|array|model property of App\User, array{\'foo\'} given.'], + 12 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Relations\HasMany::findSole() expects \'*\'|array|model property of App\Account, array{\'foo\'} given.'], + 14 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Builder::findOr() expects \'*\'|array|(Closure(): null)|model property of App\User, array{\'foo\'} given.'], + 15 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Builder::findOr() expects \'*\'|array|(Closure(): null)|model property of App\User, \'foo\' given.'], + 20 => ['Parameter #2 $columns of static method App\User::findOr() expects \'*\'|array|(Closure(): null)|model property of App\User, array{\'foo\'} given.'], + 21 => ['Parameter #2 $columns of method Hypervel\Database\Eloquent\Relations\HasMany::findOr() expects \'*\'|array|(Closure(): null)|model property of App\Account, array{\'foo\'} given.'], + ], + ]; + + yield 'force-create-quietly' => [ + __DIR__ . '/data/model-property-force-create-quietly.php', + [ + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::forceCreateQuietly() expects array, array given.'], + 9 => ['Parameter #1 $attributes of static method App\User::forceCreateQuietly() expects array, array given.'], + ], + ]; + + yield 'increment-or-create' => [ + __DIR__ . '/data/model-property-increment-or-create.php', + [ + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::incrementOrCreate() expects array, array given.'], + 7 => ['Parameter #2 $column of method Hypervel\Database\Eloquent\Builder::incrementOrCreate() expects model property of App\User, string given.'], + 9 => ['Parameter #5 $extra of method Hypervel\Database\Eloquent\Builder::incrementOrCreate() expects array, array given.'], + 12 => ['Parameter #1 $attributes of static method App\User::incrementOrCreate() expects array, array given.'], + ], + ]; + + // REMOVED: model-property-increment-or-create-relation.php; it asserts Laravel releases before 13.35. + + // Hypervel: a single row may also bind resources. + yield 'upsert' => [ + __DIR__ . '/data/model-property-upsert.php', + [ + 6 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::upsert() expects array|bool|float|int|object|resource|string|null>, array given.'], + 7 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::upsert() expects array|bool|float|int|object|resource|string|null>, array> given.'], + 8 => ['Parameter #2 $uniqueBy of method Hypervel\Database\Eloquent\Builder::upsert() expects array|model property of App\User, string given.'], + 9 => ['Parameter #2 $uniqueBy of method Hypervel\Database\Eloquent\Builder::upsert() expects array|model property of App\User, array given.'], + 10 => ['Parameter #3 $update of method Hypervel\Database\Eloquent\Builder::upsert() expects array|null, array given.'], + 18 => ['Parameter #1 $values of static method App\User::upsert() expects array|bool|float|int|object|resource|string|null>, array given.'], + 22 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::upsert() expects array|bool|float|int|object|resource|string|null>, array given.'], + 23 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::upsert() expects array|bool|float|int|object|resource|string|null>, array> given.'], + 24 => ['Parameter #2 $uniqueBy of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::upsert() expects array|model property of App\Account, string given.'], + 36 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::upsert() expects array|bool|float|int|object|resource|string|null>, array> given.'], + ], + ]; + + yield 'with-attributes' => [ + __DIR__ . '/data/model-property-with-attributes.php', + [ + 6 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\User, array given.'], + 7 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.'], + 14 => ['Parameter #1 $attributes of static method App\User::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\User, array given.'], + 18 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasMany::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\Account, array given.'], + 19 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasMany::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\Account, string given.'], + ], + ]; + + yield 'with-attributes-as-conditions' => [ + __DIR__ . '/data/model-property-with-attributes-l12-4.php', + [ + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\User, array given.'], + 6 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Builder::withAttributes() expects array|Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.'], + ], + ]; + + yield 'fill-and-insert' => [ + __DIR__ . '/data/model-property-fill-and-insert-l12-6.php', + [ + 5 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::fillAndInsert() expects array>, array> given.'], + 6 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::fillAndInsertOrIgnore() expects array>, array> given.'], + 7 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::fillAndInsertGetId() expects array, array given.'], + 8 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::fillForInsert() expects array>, array> given.'], + 16 => ['Parameter #1 $values of static method App\User::fillAndInsert() expects array>, array> given.'], + 29 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Builder::fillAndInsert() expects array>, array> given.'], + 34 => ['Parameter #1 $values of method Hypervel\Database\Eloquent\Relations\HasMany::fillAndInsert() expects array>, array> given.'], + ], + ]; + + yield 'increment-each' => [ + __DIR__ . '/data/model-property-increment-each-l13-3.php', + [ + 5 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Builder::incrementEach() expects array, array{foo: 1} given.'], + 7 => ['Parameter #2 $extra of method Hypervel\Database\Eloquent\Builder::incrementEach() expects array, array given.'], + 10 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Builder::decrementEach() expects array, array{foo: 1} given.'], + 12 => ['Parameter #2 $extra of method Hypervel\Database\Eloquent\Builder::decrementEach() expects array, array given.'], + 15 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Relations\HasMany::incrementEach() expects array, array{foo: 1} given.'], + 16 => ['Parameter #1 $columns of method Hypervel\Database\Eloquent\Relations\HasMany::decrementEach() expects array, array{foo: 1} given.'], + ], + ]; + + yield 'relation-increment-or-create' => [ + __DIR__ . '/data/model-property-relation-increment-or-create-l13-35.php', + [ + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::incrementOrCreate() expects array, array given.'], + 7 => ['Parameter #2 $column of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::incrementOrCreate() expects model property of App\Account, string given.'], + 9 => ['Parameter #5 $extra of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::incrementOrCreate() expects array, array given.'], + 12 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany::incrementOrCreate() expects array, array given.'], + 13 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::incrementOrCreate() expects array, array given.'], + 15 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\BelongsToMany::incrementOrCreate() expects array, array given.'], + 17 => ['Parameter #2 $column of method Hypervel\Database\Eloquent\Relations\BelongsToMany::incrementOrCreate() expects model property of App\Role, string given.'], + 18 => ['Parameter #5 $extra of method Hypervel\Database\Eloquent\Relations\BelongsToMany::incrementOrCreate() expects array, array given.'], + ], + ]; + + yield 'relation-increment-or-create-through' => [ + __DIR__ . '/data/model-property-relation-increment-or-create-l13-37.php', + [ + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough>::incrementOrCreate() expects array, array given.'], + 7 => ['Parameter #2 $column of method Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough::incrementOrCreate() expects model property of App\Car, string given.'], + 8 => [ + 'Parameter #2 $column of method Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough::incrementOrCreate() expects model property of App\Car, string given.', + 'Parameter #5 $extra of method Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough::incrementOrCreate() expects array, array given.', + ], + ], + ]; + + yield 'model-save-options' => [ + __DIR__ . '/data/model-save-options.php', + [ + 5 => ['Parameter #1 $options of method Hypervel\Database\Eloquent\Model::save() expects array{touch?: bool|null, ...}, array{touch: \'no\'} given.'], + 7 => ['Parameter #2 $options of method Hypervel\Database\Eloquent\Model::update() expects array{touch?: bool|null, ...}, array{touch: \'no\'} given.'], + ], + ]; + + // Hypervel: properties that only some union alternatives have may exist. + yield [ + __DIR__ . '/data/model-property-model.php', + [ + 11 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::update() expects array, array given.'], + 18 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::update() expects array, array given.'], + 48 => ['Parameter #1 $property of method ModelPropertyModel\ModelPropertyCustomMethods::foo() expects model property of App\User, string given.'], + 66 => ['Parameter #1 $property of method ModelPropertyModel\ModelPropertyCustomMethodsInNormalClass::foo() expects model property of App\User, string given.'], + 92 => ['Parameter #1 $userModelProperty of function ModelPropertyModel\acceptsUserProperty expects model property of App\User, model property of App\Account given.'], + 105 => ['Parameter #1 $accountModelProperty of function ModelPropertyModel\acceptsUserOrAccountProperty expects model property of App\Account|App\User, string given.'], + ], + ]; + + yield 'fill' => [ + __DIR__ . '/data/model-property-fill.php', + [ + 10 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::fill() expects array, array given.'], + 11 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::forceFill() expects array, array given.'], + 29 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::fill() expects array, array given.'], + 30 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::forceFill() expects array, array given.'], + ], + ]; + + // Hypervel: createQuietly() and createMany() check the attributes too. + yield [ + __DIR__ . '/data/model-property-model-factory.php', + [ + 7 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Factories\Factory::createOne() expects array|(callable(array): array), array{foo: \'bar\'} given.'], + 8 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Factories\Factory::createQuietly() expects array|(callable(array): array), array{foo: \'bar\'} given.'], + 9 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Factories\Factory::createMany() expects int|iterable>|null, array> given.'], + ], + ]; + + yield [ + __DIR__ . '/data/model-property-relation.php', + [ + 4 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Relations\HasMany::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\Account, \'foo\' given.'], + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::create() expects array, array given.'], + 6 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::firstOrNew() expects array, array given.'], + 7 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::firstOrCreate() expects array, array given.'], + 8 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::updateOrCreate() expects array, array given.'], + 10 => ['Parameter #1 $column of method Hypervel\Database\Eloquent\Relations\BelongsToMany::where() expects array|(Closure(Hypervel\Database\Query\Builder): mixed)|Hypervel\Contracts\Database\Query\Expression|Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|Hypervel\Database\Query\Builder|model property of App\Post, \'foo\' given.'], + 12 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::createOrFirst() expects array, array given.'], + ], + ]; + + yield 'relation-create-variants' => [ + __DIR__ . '/data/model-property-relation-create-variants.php', + [ + 4 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::createQuietly() expects array, array given.'], + 5 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreate() expects array, array given.'], + 6 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreateQuietly() expects array, array given.'], + 7 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::createManyQuietly() expects iterable>, array> given.'], + ], + ]; + + yield 'relation-force-create-many' => [ + __DIR__ . '/data/model-property-force-create-many-l12-8.php', + [ + 6 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreateMany() expects iterable>, array> given.'], + 7 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreateManyQuietly() expects iterable>, array> given.'], + 8 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreateMany() expects iterable>, array> given.'], + 23 => ['Parameter #1 $records of method Hypervel\Database\Eloquent\Relations\HasOneOrMany>::forceCreateMany() expects iterable>, array> given.'], + ], + ]; + + yield [ + __DIR__ . '/data/model-property-static-call.php', + [ + 10 => ['Parameter #1 $attributes of static method App\User::create() expects array, array given.'], + 14 => ['Parameter #1 $attributes of static method App\User::create() expects array, array given.'], + 26 => ['Parameter #1 $attributes of static method ModelPropertyStaticCall\ModelPropertyStaticCallsInClass::create() expects array, array given.'], + 34 => ['Parameter #1 $attributes of static method ModelPropertyStaticCall\ModelPropertyStaticCallsInClass::create() expects array, array given.'], + ], + ]; + + yield [ + __DIR__ . '/data/collection-of-type.php', + [ + 36 => ['Parameter #1 $accounts of method CollectionOfType\CollectionOfTypeTest::acceptsAccountCollection() expects App\AccountCollection<(int|string), App\Account>, Hypervel\Database\Eloquent\Collection given.'], + 39 => ['Parameter #1 $users of method CollectionOfType\CollectionOfTypeTest::acceptsUserCollection() expects Hypervel\Database\Eloquent\Collection<(int|string), App\User>, App\AccountCollection given.'], + 69 => ['Parameter #1 $collection of method CollectionOfType\GenericTest::acceptsAccountCollection() expects App\AccountCollection<(int|string), App\Account>, Hypervel\Database\Eloquent\Collection<(int|string), App\User> given.'], + ], + ]; + + yield [ + __DIR__ . '/data/builder-of-type.php', + [ + 44 => ['Parameter #1 $accountQuery of method BuilderOfType\BuilderOfTypeTest::acceptsAccountBuilder() expects Hypervel\Database\Eloquent\Builder, Hypervel\Database\Eloquent\Builder given.'], + 47 => ['Parameter #1 $userQuery of method BuilderOfType\BuilderOfTypeTest::acceptsUserBuilder() expects Hypervel\Database\Eloquent\Builder, Hypervel\Database\Eloquent\Builder given.'], + 50 => ['Parameter #1 $userQuery of method BuilderOfType\BuilderOfTypeTest::acceptsUserBuilder() expects Hypervel\Database\Eloquent\Builder, App\ChildTeamBuilder given.'], + 89 => ['Parameter #1 $builder of method BuilderOfType\GenericTest::acceptsAccountBuilder() expects Hypervel\Database\Eloquent\Builder, Hypervel\Database\Eloquent\Builder given.'], + ], + ]; + + yield [ + __DIR__ . '/data/model-property-mutator-and-casting.php', + [ + 24 => ['Parameter #1 $lineOne of class ModelPropertyMutatorAndCasting\Address constructor expects string, mixed given.'], + 25 => ['Parameter #2 $lineTwo of class ModelPropertyMutatorAndCasting\Address constructor expects string, mixed given.'], + ], + ]; + + yield [ + __DIR__ . '/../Fixtures/Integration/view-helper.php', + [ + 13 => ['Parameter #1 $view of function view expects view-string|null, string given.'], + ], + ]; + + // Hypervel: each accessor direction follows Eloquent's precedence, falling back to the attribute's column and casts. + yield [ + __DIR__ . '/data/model-accessors.php', + [ + 81 => ['Property ModelAccessors\AccessorModel::$name (string) does not accept int.'], + 86 => ['Property ModelAccessors\AccessorModel::$email (int) does not accept string.'], + 91 => ['Property ModelAccessors\AccessorModel::$virtual (string) does not accept int.'], + 95 => ['Property ModelAccessors\AccessorModel::$password (int) does not accept string.'], + ], + ]; + + // Hypervel: a stubbed repository keeps its source offset types instead of ArrayAccess's. + yield [ + __DIR__ . '/data/repository-offsets.php', + [ + 14 => ['Parameter #1 $offset of method Hypervel\Config\Repository::offsetGet() expects int|string, stdClass given.'], + 15 => ['Parameter #1 $offset of method Hypervel\Config\Repository::offsetSet() expects int|string, stdClass given.'], + 18 => ['Parameter #1 $offset of method Hypervel\Cache\Repository::offsetGet() expects string|UnitEnum, stdClass given.'], + 19 => ['Parameter #1 $offset of method Hypervel\Cache\Repository::offsetExists() expects string|UnitEnum, int given.'], + ], + ]; + + // Hypervel: a key that is not a literal may name a property, including in a non-empty array. + yield [ + __DIR__ . '/data/model-property-dynamic-keys.php', + [ + 11 => ['Parameter #1 $attributes of method Hypervel\Database\Eloquent\Model::forceFill() expects array, array given.'], + ], + ]; + + // Hypervel: a builder-of parameter of an unknown relation model accepts any builder. + yield [__DIR__ . '/data/unknown-relation-model.php']; + + // Hypervel: Builder::__callStatic() registers macros and mixins and runs only the builder's global macros, + // while a model forwards static calls through an instance. + yield [ + __DIR__ . '/data/builder-static-calls.php', + [ + 14 => ['Static call to instance method App\PostBuilder::globalCustomDatabaseQueryMacro().'], + 15 => ['Static call to instance method Hypervel\Database\Eloquent\Builder::flushMacros().'], + ], + ]; + + // Hypervel: Model::__call() cannot reach a protected builder method, so the builder's __call() runs a + // scope of that name, and throws without one. + yield [ + __DIR__ . '/data/model-protected-builder-methods.php', + [ + 45 => ['Call to an undefined static method App\User::addUpdatedAtColumn().'], + 46 => ['Call to an undefined method App\User::addUpdatedAtColumn().'], + ], + ]; + + // Hypervel: optional() passes its callback only values that are not null. + yield [__DIR__ . '/data/optional-callbacks.php']; + + // Hypervel: a higher-order proxy runs outside the item's class, so it reaches only public members. + yield [ + __DIR__ . '/data/proxied-members.php', + [ + 30 => ['Call to private method secret() of class ProxiedMembers\Score.'], + 31 => ['Access to protected property Hypervel\Support\HigherOrderCollectionProxy>::$rank.'], + ], + ]; + } + + /** + * Analyze the given file and return its errors. + * + * @see https://github.com/phpstan/phpstan-src/blob/c9772621c0bd6eab7e02fdaa03714bea239b372d/tests/PHPStan/Analyser/AnalyserIntegrationTest.php#L604-L622 + * @see https://github.com/phpstan/phpstan/discussions/6888#discussioncomment-2423613 + * + * @param null|string[] $allAnalysedFiles + * + * @return Error[] + * + * @throws Throwable + */ + private function runAnalyse(string $file, ?array $allAnalysedFiles = null): array + { + $file = $this->getFileHelper()->normalizePath($file); + + /** @var Analyser $analyser */ + $analyser = self::getContainer()->getByType(Analyser::class); + + /** @var FileHelper $fileHelper */ + $fileHelper = self::getContainer()->getByType(FileHelper::class); + + $errors = $analyser->analyse([$file], null, null, true, $allAnalysedFiles)->getErrors(); + + foreach ($errors as $error) { + $this->assertSame($fileHelper->normalizePath($file), $error->getFilePath()); + } + + return $errors; + } + + /** + * Assert that the file reports exactly the expected errors. + * + * Upstream only checks that each reported error is expected, so an expected + * error that is no longer reported would pass. + * + * @param array> $expectedErrors + * @param Error[] $errors + */ + private function assertSameErrorMessages(string $file, array $expectedErrors, array $errors): void + { + $actualErrors = []; + + foreach ($errors as $error) { + $actualErrors[$error->getLine() ?? 0][] = $error->getMessage(); + } + + $normalize = static function (array $errorsByLine): array { + ksort($errorsByLine); + + return array_map(static function (array $messages): array { + sort($messages); + + return $messages; + }, $errorsByLine); + }; + + $this->assertSame($normalize($expectedErrors), $normalize($actualErrors), sprintf('File %s reports different errors.', $file)); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../Type/data/config-check-model-properties.neon', + ]; + } +} diff --git a/tests/PHPStan/Integration/data/blade-view.php b/tests/PHPStan/Integration/data/blade-view.php new file mode 100644 index 0000000000..edeec9fb22 --- /dev/null +++ b/tests/PHPStan/Integration/data/blade-view.php @@ -0,0 +1,21 @@ +doSomethingWithAView('home'); + $this->doSomethingWithAView('emails.orders.shipped'); + $this->doSomethingWithAView('users.index'); + } + + /** + * @phpstan-param view-string $view + * + * @param string $view + * @return void + */ + private function doSomethingWithAView(string $view): void + { + } +} diff --git a/tests/PHPStan/Integration/data/bleeding-edge/collections.php b/tests/PHPStan/Integration/data/bleeding-edge/collections.php new file mode 100644 index 0000000000..033197e412 --- /dev/null +++ b/tests/PHPStan/Integration/data/bleeding-edge/collections.php @@ -0,0 +1,29 @@ + $ints */ +function takeInts(Collection $ints): void +{ +} + +/** @return Collection */ +function names(): Collection +{ + $names = collect(); + $names->push('Taylor'); + + return $names; +} + +// REMOVED: queries() searching collect([1, 2, 3]) for an int. Hypervel keeps Laravel's covariant TValue, +// and PHPStan 2.3.5 does not widen an already inferred covariant TValue to the argument. + +function wrongValue(): void +{ + $ints = collect(); + $ints->push('one'); + takeInts($ints); +} diff --git a/tests/PHPStan/Integration/data/bug-1744.php b/tests/PHPStan/Integration/data/bug-1744.php new file mode 100644 index 0000000000..ea010c43af --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-1744.php @@ -0,0 +1,21 @@ +created_at = '2024-01-01 00:00:00'; + $model->created_at = Carbon::now(); + $model->created_at = null; + $model->created_at = ['raw' => '2024-01-01 00:00:00', 'formatted' => null]; + + $model->updated_at = Carbon::now(); + $model->updated_at = '2024-01-01 00:00:00'; + + $model->deleted_at = Carbon::now(); + $model->deleted_at = '2024-01-01 00:00:00'; + $model->deleted_at = null; +} diff --git a/tests/PHPStan/Integration/data/bug-2074.php b/tests/PHPStan/Integration/data/bug-2074.php new file mode 100644 index 0000000000..6124508365 --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-2074.php @@ -0,0 +1,13 @@ + $query */ +function test(Builder $query): void +{ + assertType('Hypervel\Database\Eloquent\Builder<*>', $query->where('foo', 'bar')); +} diff --git a/tests/PHPStan/Integration/data/bug-2426.php b/tests/PHPStan/Integration/data/bug-2426.php new file mode 100644 index 0000000000..290be066c7 --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-2426.php @@ -0,0 +1,14 @@ +post('localhost', [ + [ + 'name' => 'a', + 'contents' => 'a', + ], + ]); + + Http::asMultipart()->post('localhost', [ + [ + 'name' => 'a', + 'contents' => 'a', + ], + [ + 'name' => 'b', + 'contents' => 'b', + ], + ]); +} diff --git a/tests/PHPStan/Integration/data/bug-2516.php b/tests/PHPStan/Integration/data/bug-2516.php new file mode 100644 index 0000000000..cbd0205fb6 --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-2516.php @@ -0,0 +1,54 @@ + */ + public function all(): Collection + { + return User::query() + ->get() + ->map(fn ($user) => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ]); + } + + /** @return Collection */ + public function names(): Collection + { + return User::query()->get()->mapWithKeys(fn ($user) => [$user->id => $user->name]); + } + + /** @return Collection */ + public function labels(): Collection + { + $labels = User::query()->get()->map(fn ($user) => 'unknown'); + + $labels->push('other'); + + return $labels; + } + + /** + * @param EloquentCollection $users + * + * @return Collection + */ + public function sums(EloquentCollection $users): Collection + { + $sums = $users->mapWithKeys(fn ($user) => [$user->id => 0.]); + + foreach ($users as $user) { + $sums[$user->id] += (float) $user->id; + } + + return $sums; + } +} diff --git a/tests/PHPStan/Integration/data/bug-2545.php b/tests/PHPStan/Integration/data/bug-2545.php new file mode 100644 index 0000000000..a38adb8f83 --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-2545.php @@ -0,0 +1,13 @@ +id; + } +} diff --git a/tests/PHPStan/Integration/data/bug-2582.php b/tests/PHPStan/Integration/data/bug-2582.php new file mode 100644 index 0000000000..a683101c94 --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-2582.php @@ -0,0 +1,24 @@ + $builder + * @param Collection $users + */ +function test(Builder $builder, User $user, Collection $users): void +{ + User::with(['accounts' => ['transactions', 'group' => ['users']]]); + User::with(['accounts' => ['group' => fn (Relation $relation) => $relation->latest()]]); + $builder->with(['accounts' => ['group' => ['users']]]); + $builder->withOnly(['accounts' => ['group' => fn (Relation $relation) => $relation->latest()]]); + $user->load(['accounts' => ['group' => ['users']]]); + $user->loadMissing(['accounts' => ['group' => ['users']]]); + $users->load(['accounts' => ['group' => ['users']]]); + $users->loadMissing(['accounts' => ['group' => ['users']]]); +} diff --git a/tests/PHPStan/Integration/data/bug-final_model_query.php b/tests/PHPStan/Integration/data/bug-final_model_query.php new file mode 100644 index 0000000000..a704552694 --- /dev/null +++ b/tests/PHPStan/Integration/data/bug-final_model_query.php @@ -0,0 +1,15 @@ + */ + public function test(): Builder + { + return self::query(); + } +} diff --git a/tests/PHPStan/Integration/data/builder-of-type.php b/tests/PHPStan/Integration/data/builder-of-type.php new file mode 100644 index 0000000000..6db35118fb --- /dev/null +++ b/tests/PHPStan/Integration/data/builder-of-type.php @@ -0,0 +1,117 @@ + $userQuery + */ + public function acceptsUserBuilder(Builder $userQuery): void + { + } + + /** + * @phpstan-param builder-of $accountQuery + */ + public function acceptsAccountBuilder(Builder $accountQuery): void + { + } + + /** + * @phpstan-param builder-of $teamQuery + */ + public function acceptsTeamBuilder(ChildTeamBuilder $teamQuery): void + { + } + + public function testValidUsage(): void + { + $this->acceptsUserBuilder(User::query()); + $this->acceptsAccountBuilder(Account::query()); + $this->acceptsTeamBuilder(Team::query()); + } + + public function testInvalidUsage(): void + { + // passing a User builder to a method expecting Account builder + $this->acceptsAccountBuilder(User::query()); + + // passing an Account builder to the method expecting a User builder + $this->acceptsUserBuilder(Account::query()); + + // passing a Team builder to the method expecting a User builder + $this->acceptsUserBuilder(Team::query()); + } +} + +class GenericTest { + /** + * @template TModel of \Hypervel\Database\Eloquent\Model + * @param class-string $modelClass + * + * @return builder-of + */ + function getQueryBuilder(string $modelClass): Builder + { + return $modelClass::query(); + } + + /** + * @param Builder $builder + */ + function acceptsAccountBuilder(Builder $builder): void + { + + } + + /** + * @param ChildTeamBuilder $builder + */ + function acceptsTeamBuilder(ChildTeamBuilder $builder): void + { + + } +} + +function testGenericBuilderHandler(GenericTest $generic): void +{ + $userBuilder = $generic->getQueryBuilder(User::class); + $accountBuilder = $generic->getQueryBuilder(Account::class); + $teamBuilder = $generic->getQueryBuilder(Team::class); + + $generic->acceptsAccountBuilder($userBuilder); + $generic->acceptsAccountBuilder($accountBuilder); + $generic->acceptsTeamBuilder($teamBuilder); +} + +class StaticBuilderQueries extends \Hypervel\Database\Eloquent\Model +{ + /** @return builder-of */ + public function builder(): Builder + { + return $this->newQuery(); + } + + /** @return Builder */ + public function queryKeepingStatic(): Builder + { + return $this->builder(); + } +} + +/** + * @template TModel of \Hypervel\Database\Eloquent\Model + * @param TModel $model + * @return builder-of + */ +function queryFromModel($model): Builder +{ + return $model->newQuery(); +} diff --git a/tests/PHPStan/Integration/data/builder-static-calls.php b/tests/PHPStan/Integration/data/builder-static-calls.php new file mode 100644 index 0000000000..cb564f1095 --- /dev/null +++ b/tests/PHPStan/Integration/data/builder-static-calls.php @@ -0,0 +1,16 @@ + 1); + Builder::mixin(new \stdClass); + User::globalCustomDatabaseQueryMacro(); + PostBuilder::globalCustomDatabaseQueryMacro(); + Builder::flushMacros(); +} diff --git a/tests/PHPStan/Integration/data/collection-of-type.php b/tests/PHPStan/Integration/data/collection-of-type.php new file mode 100644 index 0000000000..3ecb0180a6 --- /dev/null +++ b/tests/PHPStan/Integration/data/collection-of-type.php @@ -0,0 +1,71 @@ + $users + */ + public function acceptsUserCollection(Collection $users): void + { + } + + /** + * @phpstan-param collection-of $accounts + */ + public function acceptsAccountCollection(Collection $accounts): void + { + } + + public function testValidUsage(): void + { + $this->acceptsUserCollection(User::all()); + $this->acceptsAccountCollection(Account::all()); + } + + public function testInvalidUsage(): void + { + //passing a User collection to a method-expecting Account collection + $this->acceptsAccountCollection(User::all()); + + //passing an Account collection to the method expecting a User collection + $this->acceptsUserCollection(Account::all()); + } +} + +class GenericTest { + /** + * @template TModel of \Hypervel\Database\Eloquent\Model + * @param class-string $modelClass + * + * @return collection-of + */ + function getAllModels(string $modelClass): Collection + { + return $modelClass::all(); + } + + /** + * @param AccountCollection $collection + */ + function acceptsAccountCollection(AccountCollection $collection): void + { + + } +} + +function testGenericCollectionHandler(GenericTest $generic): void +{ + $userCollection = $generic->getAllModels(User::class); + $accountCollection = $generic->getAllModels(Account::class); + + $generic->acceptsAccountCollection($userCollection); + $generic->acceptsAccountCollection($accountCollection); +} diff --git a/tests/PHPStan/Integration/data/custom-builder-return-this.php b/tests/PHPStan/Integration/data/custom-builder-return-this.php new file mode 100644 index 0000000000..52cead7116 --- /dev/null +++ b/tests/PHPStan/Integration/data/custom-builder-return-this.php @@ -0,0 +1,44 @@ + */ + public function newEloquentBuilder($query): PostBuilder + { + return new PostBuilder($query); + } +} + +/** + * @template TModel of Post + * + * @extends Builder + */ +class PostBuilder extends Builder +{ + /** @return $this */ + public function wherePublished(): static + { + return $this->where('status', 'published'); + } + + /** @return $this */ + public function withActiveScope(): static + { + return $this->withGlobalScope('active', static fn (Builder $query) => $query); + } + + /** @return $this */ + public function withoutActiveScope(): static + { + return $this->withoutGlobalScope('active'); + } +} diff --git a/tests/PHPStan/Integration/data/eager-load-maps.php b/tests/PHPStan/Integration/data/eager-load-maps.php new file mode 100644 index 0000000000..bda43c73c8 --- /dev/null +++ b/tests/PHPStan/Integration/data/eager-load-maps.php @@ -0,0 +1,42 @@ + $users + * @param Collection $comments + * @param MorphTo $commentable + * @param array{accounts?: Closure(HasMany): void, group: Closure(BelongsTo): void} $optional + * @param 'accounts'|'group' $name + */ +function callbacks(User $user, Collection $users, Collection $comments, MorphTo $commentable, array $optional, string $name): void +{ + $user->loadCount(['posts' => fn (PostBuilder $query) => $query, 'accounts' => fn (Builder $query) => $query]); + $users->loadSum(['accounts as total' => fn (Builder $query) => $query], 'id'); + $comments->loadMorph('commentable', ['post' => ['users' => fn (BelongsToMany $relation) => $relation]]); + $commentable->constrain(['post' => fn (PostBuilder $query) => $query]); + $users->load($optional); + + $users->loadSum(['accounts as total' => fn (PostBuilder $query) => $query], 'id'); + $comments->loadMorph('commentable', ['post' => ['users' => fn (HasMany $relation) => $relation]]); + $commentable->morphWith(['post' => ['users' => fn (HasMany $relation) => $relation]]); + $commentable->constrain([User::class => fn (PostBuilder $query) => $query]); + $comments->loadMorph('commentable', [Group::class => ['users' => fn (HasMany $relation) => $relation]]); + User::query()->with($name, fn (HasMany $relation) => $relation); +} diff --git a/tests/PHPStan/Integration/data/eager-loading-callbacks.php b/tests/PHPStan/Integration/data/eager-loading-callbacks.php new file mode 100644 index 0000000000..de9a07454d --- /dev/null +++ b/tests/PHPStan/Integration/data/eager-loading-callbacks.php @@ -0,0 +1,40 @@ + $builder + * @param Collection $users + */ +function callbacks(Builder $builder, User $user, Collection $users, string $dynamic): void +{ + $builder->with(['accounts' => function (HasMany $relation): void {}]); + $builder->with(['accounts' => function (Relation $relation): void {}, 'group']); + $builder->with([$dynamic => function (Relation $relation): void {}]); + $builder->with('accounts', function (HasMany $relation): void {}); + $builder->with('accounts', 'group'); + $builder->withOnly(['group' => function (BelongsTo $relation): void {}]); + User::withOnly(['group' => function (BelongsTo $relation): void {}]); + $user->load(['accounts' => function (HasMany $relation): void {}]); + $user->load('accounts', 'group'); + $user->loadMissing(['group' => function (BelongsTo $relation): void {}]); + $users->load(['accounts' => function (HasMany $relation): void {}]); + $users->loadMissing(['group' => function (BelongsTo $relation): void {}]); + $user->posts()->with(['comments' => function (Relation $relation): void {}]); + + $builder->with(['accounts' => function (BelongsTo $relation): void {}]); + $builder->withOnly(['accounts' => function (int $relation): void {}]); + $user->load(['accounts' => function (BelongsTo $relation): void {}]); + $user->loadMissing(['group' => function (HasMany $relation): void {}]); + $users->load(['accounts' => function (BelongsTo $relation): void {}]); + $users->loadMissing(['group' => function (HasMany $relation): void {}]); + $builder->with(['missing' => function ($relation): void {}]); + $builder->with('accounts', function (BelongsTo $relation): void {}); +} diff --git a/tests/PHPStan/Integration/data/eloquent-builder-paginate.php b/tests/PHPStan/Integration/data/eloquent-builder-paginate.php new file mode 100644 index 0000000000..4524fb32a6 --- /dev/null +++ b/tests/PHPStan/Integration/data/eloquent-builder-paginate.php @@ -0,0 +1,36 @@ + */ +function perPageCallback(): LengthAwarePaginator +{ + return User::query()->paginate(fn (int $total): int => $total > 1000 ? 50 : 15); +} + +/** @return LengthAwarePaginator */ +function namedPerPageCallbackWithTotal(): LengthAwarePaginator +{ + return User::query()->paginate(perPage: fn (int $total): ?int => $total > 1000 ? 50 : null, total: 1200); +} + +/** @return LengthAwarePaginator */ +function forwardedPerPageCallback(): LengthAwarePaginator +{ + return User::paginate(static fn ($total) => 15); +} + +/** @return LengthAwarePaginator */ +function relationPerPageCallback(User $user): LengthAwarePaginator +{ + return $user->accounts()->paginate(fn (int $total): int => 15); +} + +function invalidPerPageCallback(): void +{ + User::query()->paginate(fn (string $total): int => 15); + User::query()->paginate(fn (int $total): string => '15'); +} diff --git a/tests/PHPStan/Integration/data/eloquent-where.php b/tests/PHPStan/Integration/data/eloquent-where.php new file mode 100644 index 0000000000..0cd64578d1 --- /dev/null +++ b/tests/PHPStan/Integration/data/eloquent-where.php @@ -0,0 +1,54 @@ +where(static function (QueryBuilder $query): void { + $query->selectRaw('COUNT(*)')->from('posts'); + }, '=', 2); + + $subquery = fn (QueryBuilder $query) => $query->selectRaw('COUNT(*)')->from('posts'); + $nested = static fn (EloquentBuilder $query) => $query->whereKey(1); + + User::where($subquery, 2); + $user->firstWhere($subquery, '=', 2); + User::query()->whereNot($subquery, '=', 2); + User::query()->orWhereNot($subquery, '=', 2); + $user->posts()->where($subquery, '=', 2); + Post::where($subquery, '=', 2); + + User::query()->where($nested); + User::firstWhere($nested); + User::query()->whereNot($nested, null, 2); + User::query()->orWhereNot($nested, null); + Post::where(static fn (PostBuilder $query) => $query->whereKey(1)); + + User::query()->where(column: $subquery, operator: '=', value: 2); + User::query()->where(column: $nested, value: 2); + + User::orWhere($nested); + User::query()->orWhere($subquery, null); + User::query()->orWhere($nested, null, null); + User::query()->orWhere($subquery, '=', 2); + Post::orWhere(static fn (PostBuilder $query) => $query->whereKey(1)); + + User::query()->where($subquery); + User::query()->firstWhere($subquery); + User::query()->whereNot($subquery, null); + User::query()->orWhereNot($subquery, null, null); + + User::query()->where($nested, '=', 2); + User::query()->firstWhere($nested, '=', 2); + User::query()->whereNot($nested, '=', 2); + User::query()->orWhereNot($nested, '=', 2); + User::query()->orWhere(function (int $query): void {}); +} diff --git a/tests/PHPStan/Integration/data/facades.php b/tests/PHPStan/Integration/data/facades.php new file mode 100644 index 0000000000..a4b6d7785a --- /dev/null +++ b/tests/PHPStan/Integration/data/facades.php @@ -0,0 +1,15 @@ +usingConnection(null, static fn () => 42); + + return $migrator->usingConnection($connection, static fn () => 42); +} + +function notificationId(Notification $notification): string +{ + return $notification->id ?? 'pending'; +} + +/** @param resource $stream */ +function prepareMail(MailMessage $mail, $stream): void +{ + $mail->view = null; + $mail->from = ['sender@example.com']; + $mail->from = [['sender@example.com' => 'Sender'], null, 'tracking' => 42]; + $mail->replyTo = array_filter($mail->replyTo, static fn (array $address): bool => $address[0] !== 'internal@example.com'); + $mail->replyTo = ['support' => [['support@example.com' => 'Support']]]; + $mail->attachments = array_filter($mail->attachments, static fn (array $attachment): bool => $attachment['file'] !== 'internal.pdf'); + $mail->attachments['invoice'] = ['file' => Attachment::fromData(static fn (): string => 'invoice', 'invoice.txt'), 'options' => [], 'tracking' => 42]; + $mail->rawAttachments['stream'] = ['data' => $stream, 'name' => 'invoice.txt', 'options' => [], 'tracking' => 42]; + $mail->rawAttachments = array_filter($mail->rawAttachments, static fn (array $attachment): bool => $attachment['name'] !== 'internal.txt'); +} diff --git a/tests/PHPStan/Integration/data/helpers.php b/tests/PHPStan/Integration/data/helpers.php new file mode 100644 index 0000000000..c54815979a --- /dev/null +++ b/tests/PHPStan/Integration/data/helpers.php @@ -0,0 +1,25 @@ + $user->toArray()); +} + +function retryAcceptsCallableAsSleepMilliseconds(): void +{ + retry(5, function (int $attempt): bool { + return false; + }, function (int $attempt, \Exception $e): int { + return 0; + }, function (\Exception $e): bool { + return true; + }); +} + +function translationsAcceptClosureReplacements(): void +{ + trans('Hello', ['wrap' => fn (string $chunk): string => '' . $chunk . '']); + __('Hello', ['wrap' => fn (string $chunk): string => '' . $chunk . '']); +} diff --git a/tests/PHPStan/Integration/data/model-accessors.php b/tests/PHPStan/Integration/data/model-accessors.php new file mode 100644 index 0000000000..a8050351d8 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-accessors.php @@ -0,0 +1,105 @@ + 'integer', 'blocked' => 'boolean']; + + /** @return Attribute */ + protected function name(): Attribute + { + return Attribute::get(static fn (): int => 1); + } + + /** @return Attribute */ + protected function email(): Attribute + { + return Attribute::set(static fn (int $value): string => (string) $value); + } + + /** @return Attribute */ + protected function virtual(): Attribute + { + return Attribute::set(static fn (string $value): int => (int) $value); + } + + /** @return Attribute */ + protected function blocked(): Attribute + { + return Attribute::set(static fn (bool $value): bool => $value); + } + + public function setPasswordAttribute(int $value): void + { + $this->attributes['password'] = (string) $value; + } + + public function getSecretAttribute(): int + { + return 1; + } + + public function secret(): string + { + return 'unrelated'; + } +} + +#[Table('users')] +class ParentAccessorModel extends Model +{ + public function getStringAttribute(): int + { + return 1; + } +} + +class ChildAccessorModel extends ParentAccessorModel +{ + /** @return Attribute */ + protected function string(): Attribute + { + return Attribute::get(static fn (): float => 1.5); + } +} + +function accessors(AccessorModel $model, ChildAccessorModel $child): void +{ + // A get-only Attribute reads its getter's type; Eloquent writes the column directly. + assertType('int', $model->name); + $model->name = 'name'; + $model->name = 1; + + // A set-only Attribute accepts its setter's input; Eloquent reads the column directly. + assertType('string', $model->email); + $model->email = 1; + $model->email = 'email'; + + // A virtual attribute without a getter reads its raw value through its cast, or null when it was never set. + assertType('int|null', $model->virtual); + $model->virtual = 'value'; + $model->virtual = 1; + + // A legacy setter types writes by its parameter. + $model->password = 1; + $model->password = 'password'; + + // A legacy getter applies beside a method that is not an Attribute accessor. + assertType('int', $model->secret); + + // Eloquent finds an inherited legacy getter before the child's Attribute getter. + assertType('int', $child->string); + + // A set-only Attribute serializes through the column's cast. + assertType('bool|null', $model->attributesToArray()['blocked'] ?? null); +} diff --git a/tests/PHPStan/Integration/data/model-builder.php b/tests/PHPStan/Integration/data/model-builder.php new file mode 100644 index 0000000000..4d67d7cdde --- /dev/null +++ b/tests/PHPStan/Integration/data/model-builder.php @@ -0,0 +1,49 @@ + */ + public static function testQueryStatic(): Builder + { + return static::query(); + } + + public static function testCreateStatic(): static + { + return static::query()->create(); + } + + public static function testCreateSelf(): self + { + return self::query()->create(); + } +} + +function test(User|Account $userOrAccount): void +{ + \App\User::query()->where(DB::raw('1'), 1)->get(); + + /** @see https://github.com/larastan/larastan/issues/1806 */ + \App\User::query()->orderBy(Post::query()->select('id')->whereColumn('user_id', 'users.id')); + \App\User::query()->orderByDesc(Post::query()->select('id')->whereColumn('user_id', 'users.id')); + + \App\User::query()->get()->pluck('computed'); + + /** @see https://github.com/larastan/larastan/issues/1952 */ + Team::query()->where('name', 'Team A')->orderBy('name')->get(); + + assertType('int|false', $userOrAccount->increment('counter')); + assertType('int|false', $userOrAccount->decrement('counter')); + assertType('int', $userOrAccount->incrementQuietly('counter')); + assertType('int', $userOrAccount->decrementQuietly('counter')); +} diff --git a/tests/PHPStan/Integration/data/model-cast-writes.php b/tests/PHPStan/Integration/data/model-cast-writes.php new file mode 100644 index 0000000000..052cdc0b1b --- /dev/null +++ b/tests/PHPStan/Integration/data/model-cast-writes.php @@ -0,0 +1,123 @@ +int = IntStatus::One; + $model->int = 1; + $model->int = '1'; + $model->int = 'one'; + $model->string = StringStatus::One; + $model->string = '1'; + $model->string = 1; + $model->integer = PureStatus::One; + $model->integer = 'One'; + $model->integer = 1; +} + +function testCasterNullability(CasterModel $model): void +{ + assertType('ModelCastWrites\Label', $model->nullable_collection); + $model->nullable_collection = new Label('text'); + $model->nullable_collection = null; +} + +function testUnknownNullability(UnknownTableModel $model): void +{ + $model->count = 1; + $model->count = null; +} + +// A custom cast on the soft-delete column replaces its datetime cast, so writes follow the caster. +function testSoftDeleteColumnCast(SoftDeleteCastModel $model): void +{ + assertType('ModelCastWrites\Label', $model->deleted_at); + $model->deleted_at = new Label('text'); + $model->deleted_at = new DateTimeImmutable(); +} + +enum IntStatus: int +{ + case One = 1; +} + +enum StringStatus: string +{ + case One = '1'; +} + +enum PureStatus +{ + case One; +} + +#[Table('model_with_casts')] +class EnumCastModel extends Model +{ + protected array $casts = [ + 'int' => IntStatus::class, + 'string' => StringStatus::class, + 'integer' => PureStatus::class, + ]; +} + +#[Table('model_with_casts')] +class CasterModel extends Model +{ + protected array $casts = [ + 'nullable_collection' => LabelCast::class, + ]; +} + +#[Table('model_with_casts')] +class SoftDeleteCastModel extends Model +{ + use SoftDeletes; + + protected array $casts = [ + 'deleted_at' => LabelCast::class, + ]; +} + +#[Table('missing_table')] +class UnknownTableModel extends Model +{ + protected array $casts = [ + 'count' => 'int', + ]; +} + +/** + * @implements CastsAttributes + */ +class LabelCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): Label + { + return new Label(is_string($value) ? $value : ''); + } + + /** + * @param Label $value + */ + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return $value->text; + } +} + +class Label +{ + public function __construct(public string $text) + { + } +} diff --git a/tests/PHPStan/Integration/data/model-factories.php b/tests/PHPStan/Integration/data/model-factories.php new file mode 100644 index 0000000000..95913fd22e --- /dev/null +++ b/tests/PHPStan/Integration/data/model-factories.php @@ -0,0 +1,18 @@ +create(new Sequence()); + User::factory()->createQuietly(new Sequence()); + User::factory()->make(new Sequence()); + + User::factory()->hasPosts(['title' => 'a'], ['title' => 'b']); + User::factory()->trashed('2026-01-01 00:00:00'); + Post::factory()->trashed(); + User::factory()->hasGetTable(); +} diff --git a/tests/PHPStan/Integration/data/model-properties.php b/tests/PHPStan/Integration/data/model-properties.php new file mode 100644 index 0000000000..ec91d8556e --- /dev/null +++ b/tests/PHPStan/Integration/data/model-properties.php @@ -0,0 +1,119 @@ +blocked = 0; +} + +function testBooleanAcceptsOne(User $user): void +{ + $user->blocked = 1; +} + +function testBooleanAcceptsFalse(User $user): void +{ + $user->blocked = false; +} + +function testWriteToProperty(User $user): void +{ + $user->created_at = 'test'; + $user->created_at = now(); + $user->created_at = null; + $user->created_at = BaseCarbon::now(); + $user->unsigned_integer = -5; // Should raise an error + $user->unsigned_integer_method = -5; // Should raise an error +} + +/** @return mixed */ +function testKnownColumnNameWithUnknownType(User $user) +{ + $user->unknown_column = 5; + $user->unknown_column = 'foo'; + + return $user->unknown_column; +} + +function testWriteUUIDProperty(): bool +{ + $role = new Role(); + $role->id = 'abcd-efgh-ijkl'; + + return $role->save(); +} + +function testWriteIdPropertyWhenMigrationsCouldntBeRead(): bool +{ + $group = new Group(); + $group->id = 5; + + return $group->save(); +} + +function testWriteIdPropertyWhenMigrationsCouldntBeReadAndKeyTypeIsString(): bool +{ + $team = new Team(); + $team->id = 'five'; + + return $team->save(); +} + +function testDateCast(User $user): ?BaseCarbon +{ + assertType('string', $user->email); + $user->email_verified_at = now(); + + return $user->email_verified_at; +} + +function testNullablePropertyWithCast(User $user): void +{ + $user->email_verified_at = null; +} + +function testWriteToSoftDeletesColumn(User $user): void +{ + $user->deleted_at = 'test'; + $user->deleted_at = now(); + $user->deleted_at = null; + $user->deleted_at = BaseCarbon::now(); +} + +function testInboundCast(User $user): void +{ + $user->secret = 'secret'; +} + +function testWriteToDecimalCast(User $user): void +{ + $user->decimal = '1.5'; + $user->decimal = 1; + $user->decimal = 1.5; + $user->decimal = 'foo'; // Should raise an error + $user->decimal = []; // Should raise an error +} + +function testWriteToNullableDecimalCast(User $user): void +{ + $user->nullable_decimal = '1.5'; + $user->nullable_decimal = 1; + $user->nullable_decimal = 1.5; + $user->nullable_decimal = null; + $user->nullable_decimal = 'foo'; // Should raise an error + $user->nullable_decimal = []; // Should raise an error +} + +function testWriteToEnumCollectionCast(User $user, App\Casts\BackedEnumeration $backed, App\Casts\BasicEnumeration $basic): void +{ + $user->enum_collection = [$backed]; + $user->enum_collection = null; + $user->enum_collection = [$basic]; // Should raise an error +} diff --git a/tests/PHPStan/Integration/data/model-property-builder.php b/tests/PHPStan/Integration/data/model-property-builder.php new file mode 100644 index 0000000000..a64e7f04fe --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-builder.php @@ -0,0 +1,64 @@ + $builder + * @param 'unionNotExisting'|'id' $union + */ +function test(Builder $builder, User $user, string $union): void +{ + User::query()->firstWhere('foo', 'bar'); + User::query()->firstWhere($union, 'bar'); + User::query()->where('foo', 'bar')->get(); + + $builder->where('foo', 'bar'); + $user->where('foo', 'bar'); + + User::query()->firstWhere(DB::raw('name LIKE \'%john%\'')); + + User::query()->where(getKey(), '=', 'foo'); + User::query()->orWhere('foo', '=', 'foo'); + User::query()->orWhere('foo', 'foo'); + User::query()->orWhere([ + 'foo' => 'foo', + ]); + User::query()->value('foo'); + + User::query()->where('propertyDefinedOnlyInAnnotation', 'foo'); + User::query()->where('only_available_with_accessor', 'foo'); + + User::query()->first(['foo', 'bar']); + User::query()->first('foo'); + + // Joins + User::query()->join('roles', 'users.role_id', '=', 'roles.id')->where('roles.foo', 'admin'); + + User::query()->get(DB::raw('name')); + User::query()->get([DB::raw('name')]); + + User::query()->createQuietly(['foo' => 'bar']); + User::query()->createQuietly(['id' => 1]); +} + +User::query()->whereHas('accounts', function (Builder $query) { + $query->where('active', 1); + $query->where('foo', 'bar'); +}); + +User::orWhereHas('accounts', function (Builder $query) { + $query->where('foo', 5); +}); + +function getKey(): string +{ + if (random_int(0, 1)) { + return 'foo'; + } + + return 'bar'; +} diff --git a/tests/PHPStan/Integration/data/model-property-dynamic-keys.php b/tests/PHPStan/Integration/data/model-property-dynamic-keys.php new file mode 100644 index 0000000000..01ec236536 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-dynamic-keys.php @@ -0,0 +1,12 @@ +forceFill([$column => 'value']); + User::query()->update([$column => 'value']); + $user->forceFill(['missing_column' => 'value']); +} diff --git a/tests/PHPStan/Integration/data/model-property-fill-and-insert-l12-6.php b/tests/PHPStan/Integration/data/model-property-fill-and-insert-l12-6.php new file mode 100644 index 0000000000..1ebf44358d --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-fill-and-insert-l12-6.php @@ -0,0 +1,35 @@ +fillAndInsert([['email' => 'taylor@example.com', 'foo' => 'bar']]); +User::query()->fillAndInsertOrIgnore([['email' => 'taylor@example.com', 'foo' => 'bar']]); +User::query()->fillAndInsertGetId(['email' => 'taylor@example.com', 'foo' => 'bar']); +User::query()->fillForInsert([['email' => 'taylor@example.com', 'foo' => 'bar']]); + +User::query()->fillAndInsert([['email' => 'taylor@example.com', 'name' => 'Taylor'], ['email' => 'abigail@example.com', 'name' => 'Abigail']]); +User::query()->fillAndInsertOrIgnore([['email' => 'taylor@example.com', 'name' => 'Taylor']]); +User::query()->fillAndInsertGetId(['email' => 'taylor@example.com', 'name' => 'Taylor']); +User::query()->fillForInsert([['email' => 'taylor@example.com', 'name' => 'Taylor']]); +User::query()->fillAndInsert([]); + +User::fillAndInsert([['email' => 'taylor@example.com', 'foo' => 'bar']]); +User::fillAndInsertGetId(['email' => 'taylor@example.com', 'name' => 'Taylor']); + +/** @param array $rows */ +function fillAndInsertRowsWithIntKeys(array $rows): void +{ + User::query()->fillAndInsert($rows); + User::query()->fillForInsert($rows); +} + +/** @param array $rows */ +function fillAndInsertInvalidRowsWithIntKeys(array $rows): void +{ + User::query()->fillAndInsert($rows); +} + +function fillAndInsertThroughRelation(User $user): void +{ + $user->accounts()->fillAndInsert([['name' => 'foo', 'foo' => 'bar']]); +} diff --git a/tests/PHPStan/Integration/data/model-property-fill.php b/tests/PHPStan/Integration/data/model-property-fill.php new file mode 100644 index 0000000000..f4053eeb4a --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-fill.php @@ -0,0 +1,32 @@ +fill(['foo' => 'bar']); + $user->forceFill(['foo' => 'bar'])->save(); + + $user->fill(['email' => 'taylor@example.com', 'name' => 'Taylor']); + $user->forceFill(['email' => 'taylor@example.com'])->save(); + $user->fill([]); +} + +/** @param array $attributes */ +function fillUserWithValidatedInput(User $user, array $attributes): void +{ + $user->fill($attributes); + $user->forceFill($attributes); +} + +class FillOnModel extends Model +{ + public function foo(): void + { + $this->fill(['foo' => 'bar']); + $this->forceFill(['foo' => 'bar']); + } +} diff --git a/tests/PHPStan/Integration/data/model-property-find-sole-find-or.php b/tests/PHPStan/Integration/data/model-property-find-sole-find-or.php new file mode 100644 index 0000000000..3099aa2e90 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-find-sole-find-or.php @@ -0,0 +1,21 @@ +findSole(1, ['foo']); +User::query()->findSole(1, 'foo'); +User::query()->findSole(1, ['id', 'email']); +User::query()->findSole(1, '*'); +User::query()->findSole(1, DB::raw('name')); +User::findSole(1, ['foo']); +(new User())->accounts()->findSole(1, ['foo']); + +User::query()->findOr(1, ['foo'], fn () => null); +User::query()->findOr(1, 'foo', fn () => null); +User::query()->findOr(1, ['id', 'email'], fn () => null); +User::query()->findOr(1, [DB::raw('name')], fn () => null); +User::query()->findOr(1, fn () => null); +User::query()->findOr(1, callback: fn () => null); +User::findOr(1, ['foo'], fn () => null); +(new User())->accounts()->findOr(1, ['foo'], fn () => null); diff --git a/tests/PHPStan/Integration/data/model-property-force-create-many-l12-8.php b/tests/PHPStan/Integration/data/model-property-force-create-many-l12-8.php new file mode 100644 index 0000000000..68ac5ae97a --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-force-create-many-l12-8.php @@ -0,0 +1,24 @@ +accounts()->forceCreateMany([['foo' => 'bar']]); +$user->accounts()->forceCreateManyQuietly([['foo' => 'bar']]); +$user->address()->forceCreateMany([['foo' => 'bar']]); + +$user->accounts()->forceCreateMany([['name' => 'foo']]); +$user->accounts()->forceCreateManyQuietly([['name' => 'foo']]); +$user->address()->forceCreateMany([]); + +/** @param array $records */ +function forceCreateManyWithIntKeys(User $user, array $records): void +{ + $user->accounts()->forceCreateMany($records); +} + +/** @param array $records */ +function forceCreateManyInvalidWithIntKeys(User $user, array $records): void +{ + $user->accounts()->forceCreateMany($records); +} diff --git a/tests/PHPStan/Integration/data/model-property-force-create-quietly.php b/tests/PHPStan/Integration/data/model-property-force-create-quietly.php new file mode 100644 index 0000000000..bc9b1d5048 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-force-create-quietly.php @@ -0,0 +1,11 @@ +forceCreateQuietly(['foo' => 'bar']); +User::query()->forceCreateQuietly(['id' => 1]); +User::query()->forceCreateQuietly(); + +User::forceCreateQuietly(['foo' => 'bar']); +User::forceCreateQuietly(['id' => 1]); +User::forceCreateQuietly(); diff --git a/tests/PHPStan/Integration/data/model-property-increment-each-l13-3.php b/tests/PHPStan/Integration/data/model-property-increment-each-l13-3.php new file mode 100644 index 0000000000..5672ee06c5 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-increment-each-l13-3.php @@ -0,0 +1,16 @@ +incrementEach(['foo' => 1]); +User::query()->incrementEach(['id' => 1]); +User::query()->incrementEach(['id' => 1], ['foo' => 'bar']); +User::query()->incrementEach(['id' => 1], ['name' => 'bar']); + +User::query()->decrementEach(['foo' => 1]); +User::query()->decrementEach(['id' => 1]); +User::query()->decrementEach(['id' => 1], ['foo' => 'bar']); +User::query()->decrementEach(['id' => 1], ['name' => 'bar']); + +(new User())->accounts()->incrementEach(['foo' => 1]); +(new User())->accounts()->decrementEach(['foo' => 1]); diff --git a/tests/PHPStan/Integration/data/model-property-increment-or-create.php b/tests/PHPStan/Integration/data/model-property-increment-or-create.php new file mode 100644 index 0000000000..de6e601a04 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-increment-or-create.php @@ -0,0 +1,12 @@ +incrementOrCreate(['foo' => 'bar']); +User::query()->incrementOrCreate(['email' => 'taylor@example.com']); +User::query()->incrementOrCreate(['email' => 'taylor@example.com'], 'foo'); +User::query()->incrementOrCreate(['email' => 'taylor@example.com'], 'integer'); +User::query()->incrementOrCreate(['email' => 'taylor@example.com'], 'integer', 1, 1, ['foo' => 'bar']); +User::query()->incrementOrCreate(['email' => 'taylor@example.com'], 'integer', 1, 1, ['name' => 'bar']); + +User::incrementOrCreate(['foo' => 'bar']); diff --git a/tests/PHPStan/Integration/data/model-property-model-factory.php b/tests/PHPStan/Integration/data/model-property-model-factory.php new file mode 100644 index 0000000000..69de497c06 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-model-factory.php @@ -0,0 +1,10 @@ +createOne(['foo' => 'bar']); + \App\User::factory()->createQuietly(['foo' => 'bar']); + \App\User::factory()->createMany([['foo' => 'bar']]); +} diff --git a/tests/PHPStan/Integration/data/model-property-model.php b/tests/PHPStan/Integration/data/model-property-model.php new file mode 100644 index 0000000000..9b7c8f2251 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-model.php @@ -0,0 +1,110 @@ +update([ + 'foo' => 'bar', + ]); + } + + public function unionMethod(\App\User|\App\Account $model): void + { + $model->update([ + 'foo' => 'bar', + ]); + } + + public function unionMethodWithPropertyOnlyInOne(\App\User|\App\Account $model): void + { + $model->update(['email_verified_at' => 'bar']); + } + + public function unionMethodGreen(\App\User|\App\Account $model): void + { + $model->update(['id' => 5]); + } +} + +class ModelPropertyCustomMethods extends Model +{ + /** + * @phpstan-param model-property<\App\User> $property + * + * @param string $property + */ + public function foo(string $property): void + { + } + + public function bar(): void + { + $this->foo('email'); // 'email' exists on \App\User + $this->foo('foo'); // 'foo' does not exist on \App\User + } +} + +class ModelPropertyCustomMethodsInNormalClass +{ + /** + * @phpstan-param model-property<\App\User> $property + * + * @param string $property + */ + public function foo(string $property): void + { + } + + public function bar(): void + { + $this->foo('email'); // 'email' exists on \App\User + $this->foo('foo'); // 'foo' does not exist on \App\User + } +} + +/** + * @param model-property<\App\User> $userModelProperty + */ +function acceptsUserProperty(string $userModelProperty): void +{ +} + +/** + * @param model-property<\App\Account> $accountModelProperty + */ +function acceptsAccountProperty(string $accountModelProperty): void +{ +} + +function getString(): string +{ + return 'string'; +} + +/** @param model-property<\App\Account> $property*/ +function getAccountProperty(string $property): void +{ + acceptsUserProperty($property); +} + +acceptsUserProperty(getString()); + +/** + * @param model-property<\App\Account|\App\User> $accountModelProperty + */ +function acceptsUserOrAccountProperty(string $accountModelProperty): void +{ +} + +acceptsUserOrAccountProperty('id'); // id exists in both models +acceptsUserOrAccountProperty('email'); // email exists only in User + +function testModelPropertyAlias(): void +{ + \App\User::find(1, ['id as user_id', 'users.name as user_name']); +} diff --git a/tests/PHPStan/Integration/data/model-property-mutator-and-casting.php b/tests/PHPStan/Integration/data/model-property-mutator-and-casting.php new file mode 100644 index 0000000000..e43b65e1b8 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-mutator-and-casting.php @@ -0,0 +1,33 @@ + + */ + protected function address(): Attribute + { + return Attribute::make( + get: fn(mixed $value, array $attributes) => new Address( + $attributes['address_line_one'], + $attributes['address_line_two'], + ), + set: fn(Address $value) => [ + 'address_line_one' => $value->lineOne, + 'address_line_two' => $value->lineTwo, + ], + ); + } +} diff --git a/tests/PHPStan/Integration/data/model-property-relation-create-variants.php b/tests/PHPStan/Integration/data/model-property-relation-create-variants.php new file mode 100644 index 0000000000..b75d9dc0f1 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-relation-create-variants.php @@ -0,0 +1,14 @@ +accounts()->createQuietly(['foo' => 'bar']); +$user->accounts()->forceCreate(['foo' => 'bar']); +$user->accounts()->forceCreateQuietly(['foo' => 'bar']); +$user->accounts()->createManyQuietly([['foo' => 'bar']]); + +$user->accounts()->createQuietly(['name' => 'foo']); +$user->accounts()->forceCreate(['name' => 'foo']); +$user->accounts()->forceCreateQuietly(['name' => 'foo']); +$user->accounts()->createManyQuietly([['name' => 'foo']]); +$user->accounts()->createQuietly(); +$user->accounts()->forceCreateQuietly(); diff --git a/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-35.php b/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-35.php new file mode 100644 index 0000000000..766d736675 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-35.php @@ -0,0 +1,18 @@ +accounts()->incrementOrCreate(['foo' => 'bar']); +(new User())->accounts()->incrementOrCreate(['name' => 'taylor']); +(new User())->accounts()->incrementOrCreate(['name' => 'taylor'], 'foo'); +(new User())->accounts()->incrementOrCreate(['name' => 'taylor'], 'active'); +(new User())->accounts()->incrementOrCreate(['name' => 'taylor'], 'active', 1, 1, ['foo' => 'bar']); +(new User())->accounts()->incrementOrCreate(['name' => 'taylor'], 'active', 1, 1, ['name' => 'otwell']); + +(new User())->oneAddress()->incrementOrCreate(['foo' => 'bar']); +(new User())->address()->incrementOrCreate(['foo' => 'bar']); + +(new User())->roles()->incrementOrCreate(['foo' => 'bar']); +(new User())->roles()->incrementOrCreate(['name' => 'admin']); +(new User())->roles()->incrementOrCreate(['name' => 'admin'], 'foo'); +(new User())->roles()->incrementOrCreate(['name' => 'admin'], 'name', 1, 1, ['foo' => 'bar']); diff --git a/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-37.php b/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-37.php new file mode 100644 index 0000000000..e476ea14bf --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-relation-increment-or-create-l13-37.php @@ -0,0 +1,8 @@ +transactions()->incrementOrCreate(['foo' => 'bar']); +(new User())->transactions()->incrementOrCreate([]); +(new User())->car()->incrementOrCreate([], 'foo'); +(new User())->car()->incrementOrCreate([], 'foo', 1, 1, ['foo' => 'bar']); diff --git a/tests/PHPStan/Integration/data/model-property-relation.php b/tests/PHPStan/Integration/data/model-property-relation.php new file mode 100644 index 0000000000..4b4c66d30e --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-relation.php @@ -0,0 +1,17 @@ +accounts()->where('foo', 'bar'); +$user->accounts()->create(['foo' => 'bar']); +$user->accounts()->firstOrNew(['foo' => 'bar']); +$user->accounts()->firstOrCreate(['foo' => 'bar']); +$user->accounts()->updateOrCreate(['foo' => 'bar']); + +$user->posts()->where('foo', 'bar'); + +$user->accounts()->createOrFirst(['foo' => 'bar']); + +$user->accountsCamel()->createMany([ + ['name' => 'foo'], + ['name' => 'bar'], +]); diff --git a/tests/PHPStan/Integration/data/model-property-static-call.php b/tests/PHPStan/Integration/data/model-property-static-call.php new file mode 100644 index 0000000000..37c401c8a5 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-static-call.php @@ -0,0 +1,39 @@ + 'bar', + ]); + + return $foo::create([ + 'foo' => 'bar', + ]); +} + +/** + * @property string $name + */ +class ModelPropertyStaticCallsInClass extends Model +{ + public static function foo(): ModelPropertyStaticCallsInClass + { + return static::create([ + 'foo' => 'bar', + 'name' => 'John Doe', + ]); + } + + public function bar(): ModelPropertyStaticCallsInClass + { + return self::create([ + 'foo' => 'bar', + 'name' => 'John Doe', + ]); + } +} diff --git a/tests/PHPStan/Integration/data/model-property-upsert.php b/tests/PHPStan/Integration/data/model-property-upsert.php new file mode 100644 index 0000000000..bf82c56909 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-upsert.php @@ -0,0 +1,44 @@ +upsert(['foo' => 'bar'], 'email'); +User::query()->upsert([['email' => 'taylor@example.com', 'foo' => 'bar']], 'email'); +User::query()->upsert(['email' => 'taylor@example.com'], 'foo'); +User::query()->upsert(['email' => 'taylor@example.com'], ['email', 'foo']); +User::query()->upsert(['email' => 'taylor@example.com'], 'email', ['foo' => DB::raw('1')]); + +User::query()->upsert(['email' => 'taylor@example.com', 'name' => 'Taylor'], 'email'); +User::query()->upsert([['email' => 'taylor@example.com', 'name' => 'Taylor'], ['email' => 'abigail@example.com', 'name' => 'Abigail']], ['email'], ['name']); +User::query()->upsert(['email' => 'taylor@example.com', 'name' => 'Taylor'], 'email', ['name' => DB::raw('upper(name)')]); +User::query()->upsert(['email' => 'taylor@example.com', 'email_verified_at' => now()], 'email', null); +User::query()->upsert([], 'email'); + +User::upsert(['foo' => 'bar'], 'email'); +User::upsert(['email' => 'taylor@example.com', 'name' => 'Taylor'], 'email', ['name']); + +/** @var User $user */ +$user->accounts()->upsert(['foo' => 'bar'], 'name'); +$user->accounts()->upsert([['name' => 'foo', 'foo' => 'bar']], 'name'); +$user->accounts()->upsert(['name' => 'foo'], 'foo'); +$user->accounts()->upsert([['name' => 'foo', 'active' => '1']], ['name'], ['active']); + +/** @param array $rows */ +function upsertRowsWithIntKeys(array $rows): void +{ + User::query()->upsert($rows, 'email'); +} + +/** @param array $rows */ +function upsertInvalidRowsWithIntKeys(array $rows): void +{ + User::query()->upsert($rows, 'email'); +} + +// Hypervel: PDO binds resource values as large objects. +/** @param resource $name */ +function upsertResourceValue($name): void +{ + User::query()->upsert(['email' => 'taylor@example.com', 'name' => $name], 'email'); +} diff --git a/tests/PHPStan/Integration/data/model-property-with-attributes-l12-4.php b/tests/PHPStan/Integration/data/model-property-with-attributes-l12-4.php new file mode 100644 index 0000000000..d095768e0f --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-with-attributes-l12-4.php @@ -0,0 +1,9 @@ +withAttributes(['email' => 'taylor@example.com', 'foo' => 'bar'], asConditions: false); +User::query()->withAttributes('foo', 'bar', false); + +User::query()->withAttributes(['email' => 'taylor@example.com'], asConditions: false); +User::query()->withAttributes('email', 'taylor@example.com', false); diff --git a/tests/PHPStan/Integration/data/model-property-with-attributes.php b/tests/PHPStan/Integration/data/model-property-with-attributes.php new file mode 100644 index 0000000000..bdc1b5d885 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-property-with-attributes.php @@ -0,0 +1,26 @@ +withAttributes(['foo' => 'bar']); +User::query()->withAttributes('foo', 'bar'); + +User::query()->withAttributes(['email' => 'taylor@example.com', 'name' => 'Taylor']); +User::query()->withAttributes('email', 'taylor@example.com'); +User::query()->withAttributes(DB::raw('email'), 'taylor@example.com'); +User::query()->where('name', 'Taylor')->withAttributes('email', 'taylor@example.com')->create(); + +User::withAttributes(['foo' => 'bar']); +User::withAttributes(['email' => 'taylor@example.com']); + +/** @var User $user */ +$user->accounts()->withAttributes(['foo' => 'bar']); +$user->accounts()->withAttributes('foo', 'bar'); +$user->accounts()->withAttributes(['name' => 'foo']); + +/** @param array $attributes */ +function withAttributesFromStringKeys(array $attributes): void +{ + User::query()->withAttributes($attributes); +} diff --git a/tests/PHPStan/Integration/data/model-protected-builder-methods.php b/tests/PHPStan/Integration/data/model-protected-builder-methods.php new file mode 100644 index 0000000000..4ff1ba9060 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-protected-builder-methods.php @@ -0,0 +1,53 @@ + $query + */ + #[Scope] + protected function enforceOrderBy(Builder $query): void + { + $query->orderBy('id'); + } +} + +#[UseEloquentBuilder(LedgerBuilder::class)] +class Ledger extends Model +{ + /** + * @param Builder $query + */ + #[Scope] + protected function enforceOrderBy(Builder $query): void + { + $query->orderBy('id'); + } +} + +/** + * @extends Builder + */ +class LedgerBuilder extends Builder +{ +} + +function calls(User $user, Report $report): void +{ + User::addUpdatedAtColumn([]); + $user->addUpdatedAtColumn([]); + Report::enforceOrderBy()->get(); + $report->enforceOrderBy()->get(); + + // A builder that fixes its model reaches the scope too. A generic builder cannot: PHPStan asks extensions + // about a name its class declares with the builder's model unresolved. + Ledger::query()->enforceOrderBy()->get(); +} diff --git a/tests/PHPStan/Integration/data/model-save-options.php b/tests/PHPStan/Integration/data/model-save-options.php new file mode 100644 index 0000000000..69237db8bd --- /dev/null +++ b/tests/PHPStan/Integration/data/model-save-options.php @@ -0,0 +1,7 @@ +save(['touch' => false]); +$user->save(['touch' => 'no']); +$user->save(); +$user->update(['name' => 'Taylor'], ['touch' => 'no']); diff --git a/tests/PHPStan/Integration/data/model-with-relations.php b/tests/PHPStan/Integration/data/model-with-relations.php new file mode 100644 index 0000000000..28dd74b9e9 --- /dev/null +++ b/tests/PHPStan/Integration/data/model-with-relations.php @@ -0,0 +1,40 @@ + function (HasMany $relation): void {}]); + $user->with(['group' => function (BelongsTo $relation): void {}]); + User::with([ + 'accounts' => function (HasMany $relation): void {}, + 'group' => function (BelongsTo $relation): void {}, + ]); + User::with(['accounts' => function (Relation $relation): void {}]); + User::with([$dynamic => function (Relation $relation): void {}]); + + // Hypervel: each key's callback is checked against that key's own relation. + User::with([ + 'accounts' => function (BelongsTo $relation): void {}, + 'group' => function (HasMany $relation): void {}, + ]); + + User::with([$dynamic => function (HasMany $relation): void {}]); + User::with(['accounts' => function (BelongsTo $relation): void {}]); + User::with(['accounts' => function (int $relation): void {}]); + User::with(['missing' => function ($relation): void {}]); + User::with(['accounts' => function (HasMany $relation): void {}, $dynamic => function ($relation): void {}]); + + acceptsAccounts($user->accounts()); + acceptsAccounts($user->group()); +} + +/** @param relation-of $relation */ +function acceptsAccounts(Relation $relation): void +{ +} diff --git a/tests/PHPStan/Integration/data/optional-callbacks.php b/tests/PHPStan/Integration/data/optional-callbacks.php new file mode 100644 index 0000000000..ae2dc85e0d --- /dev/null +++ b/tests/PHPStan/Integration/data/optional-callbacks.php @@ -0,0 +1,23 @@ + + */ +function test(?Bar $maybeBar, ?int $maybeInt): array +{ + return [ + optional($maybeBar, fn (Bar $bar): string => $bar->name()), + optional($maybeInt, fn (int $value): int => $value + 1), + optional($maybeBar, fn ($bar) => $bar->name()), + ]; +} diff --git a/tests/PHPStan/Integration/data/proxied-members.php b/tests/PHPStan/Integration/data/proxied-members.php new file mode 100644 index 0000000000..c3dbdc88f9 --- /dev/null +++ b/tests/PHPStan/Integration/data/proxied-members.php @@ -0,0 +1,33 @@ +secret(); + } + + private function secret(): int + { + return $this->rank; + } +} + +/** + * @param Collection $scores + * + * @return list + */ +function test(Collection $scores): array +{ + return [ + $scores->map->secret(), + $scores->map->rank, + ]; +} diff --git a/tests/PHPStan/Integration/data/relation-cursor-paginate.php b/tests/PHPStan/Integration/data/relation-cursor-paginate.php new file mode 100644 index 0000000000..ef684d8b53 --- /dev/null +++ b/tests/PHPStan/Integration/data/relation-cursor-paginate.php @@ -0,0 +1,33 @@ +roles()->cursorPaginate(cursor: $cursor); + $user->roles()->cursorPaginate(15, ['*'], 'cursor', $cursor); + $user->roles()->cursorPaginate(cursor: 'eyJpZCI6MX0'); + $user->roles()->cursorPaginate(cursor: null); +} + +function hasManyThrough(User $user, Cursor $cursor): void +{ + $user->transactions()->cursorPaginate(cursor: $cursor); + $user->transactions()->cursorPaginate(15, ['*'], 'cursor', $cursor); + $user->transactions()->cursorPaginate(cursor: 'eyJpZCI6MX0'); + $user->transactions()->cursorPaginate(cursor: null); +} + +function hasOneThrough(User $user, Cursor $cursor): void +{ + $user->car()->cursorPaginate(cursor: $cursor); +} + +function builderAndHasMany(User $user, Cursor $cursor): void +{ + User::query()->cursorPaginate(cursor: $cursor); + $user->accounts()->cursorPaginate(cursor: $cursor); +} diff --git a/tests/PHPStan/Integration/data/relationship-query-callbacks.php b/tests/PHPStan/Integration/data/relationship-query-callbacks.php new file mode 100644 index 0000000000..c5049d0e72 --- /dev/null +++ b/tests/PHPStan/Integration/data/relationship-query-callbacks.php @@ -0,0 +1,144 @@ +has('accounts', callback: function (Builder $query) { + $query->whereKey(1); + }); + + User::doesntHave('posts', callback: function (PostBuilder $query) { + $query->whereKey(1); + }); + + User::query()->whereHas($user->posts(), function (PostBuilder $query) { + $query->whereKey(1); + }); + + User::query()->orWhereHas('posts.comments', function (Builder $query) { + $query->whereKey(1); + }); + + User::query()->whereDoesntHave('accounts'); + User::orWhereDoesntHave('accounts', null); + User::query()->whereHas('accounts', count: new Expression('1')); +} + +function morphCallbacks(Comment $comment): void +{ + Comment::query()->whereHasMorph('commentable', [Post::class], function (PostBuilder $query, string $type) { + $query->whereKey(1); + }); + + Comment::whereHasMorph($comment->commentable(), '*', function (Builder $query) { + $query->whereKey(1); + }); + + Comment::query()->whereHasMorph('commentable', [], function (Builder $query) { + $query->whereKey(1); + }); +} + +function declaredMorphCallbacks(PostComment $comment, string $unknown): void +{ + PostComment::query()->whereHasMorph('commentable', '*', function (PostBuilder $query) { + $query->whereKey(1); + }); + + PostComment::whereHasMorph('commentable', $unknown, function (PostBuilder $query) { + $query->whereKey(1); + }); + + PostComment::whereHasMorph($comment->commentable(), Post::class, function (PostBuilder $query) { + $query->whereKey(1); + }); +} + +function invalidMorphCallbacks(): void +{ + Comment::whereHasMorph('commentable', Post::class, function (int $query) {}); + Comment::whereHasMorph('commentable', Post::class, function (Builder $query, int $type) {}); + Comment::whereHasMorph('commentable', User::class, function (PostBuilder $query) {}); +} + +function eagerCallbacks(): void +{ + User::withWhereHas('accounts', function (Builder|Relation $query) { + $query->where('active', true); + }); + + User::query()->withWhereHas('posts.comments', function (Builder|Relation $query) { + $query->whereKey(1); + }); + + User::withWhereHas(relation: 'posts', callback: function ($query) { + $query->whereKey(1); + }, count: new Expression('1')); + + User::withWhereHas('accounts'); + User::query()->withWhereHas('accounts', null); + + User::withWhereHas('accounts', function (Builder $query) {}); + User::query()->withWhereHas('accounts', function (Relation $query) {}); +} + +function shortcutCallbacks(User $user, Comment $comment): void +{ + User::whereRelation('posts', fn (PostBuilder $query) => $query->whereKey(1)); + User::query()->orWhereRelation($user->posts(), fn (PostBuilder $query) => $query->whereKey(1)); + User::whereDoesntHaveRelation('posts', fn (PostBuilder $query) => $query->whereKey(1)); + User::orWhereDoesntHaveRelation('posts', fn (PostBuilder $query) => $query->whereKey(1)); + + Comment::whereMorphRelation('commentable', Post::class, fn (PostBuilder $query) => $query->whereKey(1)); + Comment::query()->orWhereMorphRelation('commentable', [Post::class], fn (PostBuilder $query) => $query->whereKey(1)); + Comment::whereMorphDoesntHaveRelation('commentable', Post::class, fn (PostBuilder $query) => $query->whereKey(1)); + Comment::orWhereMorphDoesntHaveRelation($comment->commentable(), Post::class, fn (PostBuilder $query) => $query->whereKey(1)); + + User::withWhereRelation('posts', fn (PostBuilder|Relation $query) => $query->whereKey(1)); + User::withWhereHas('posts', fn (PostBuilder|Relation $query) => $query->whereKey(1)); + User::withWhereHas('posts:id'); + User::query()->with('accounts', fn (Relation $query) => $query->where('id', 1)); + + User::whereRelation('accounts', 'id', 1); + User::orWhereRelation('accounts', ['id' => 1]); + User::whereDoesntHaveRelation('accounts', new Expression('id'), '=', 1); + User::orWhereDoesntHaveRelation('accounts', 'id', '>', 1); + User::withWhereRelation('accounts', 'id', 1); + Comment::whereMorphRelation('commentable', Post::class, 'id', 1); + Comment::orWhereMorphRelation('commentable', Post::class, ['id' => 1]); + Comment::whereMorphDoesntHaveRelation('commentable', Post::class, new Expression('id'), '=', 1); + Comment::orWhereMorphDoesntHaveRelation('commentable', Post::class, 'id', '>', 1); + User::query()->with('accounts', 'posts'); +} + +function invalidShortcutCallbacks(): void +{ + User::whereRelation('accounts', function (PostBuilder $query) {}); + Comment::whereMorphRelation('commentable', Post::class, function (int $query) {}); + Comment::whereMorphRelation('commentable', Post::class, function (PostBuilder $query, string $type) {}); + User::withWhereRelation('accounts', function (Builder $query) {}); + User::query()->with('accounts', function (Builder $query) {}); + + User::withWhereHas('accounts', function (Builder|\Hypervel\Database\Eloquent\Relations\HasMany $query) {}); + User::withWhereRelation('accounts', function (Builder|\Hypervel\Database\Eloquent\Relations\HasMany $query) {}); +} + +/** + * @param \Closure(Builder): void $userCallback + * @param \Closure(PostBuilder): void $postCallback + */ +function typedMorphCallbacks(\Closure $userCallback, \Closure $postCallback): void +{ + Comment::whereHasMorph('commentable', Post::class, $userCallback); + Comment::whereHasMorph('commentable', Post::class, $postCallback); +} diff --git a/tests/PHPStan/Integration/data/repository-offsets.php b/tests/PHPStan/Integration/data/repository-offsets.php new file mode 100644 index 0000000000..44d3f166e5 --- /dev/null +++ b/tests/PHPStan/Integration/data/repository-offsets.php @@ -0,0 +1,20 @@ +offsetGet('app.name'); + $config->offsetGet(new stdClass()); + $config->offsetSet(new stdClass(), 'value'); + + $cache->offsetGet('key'); + $cache->offsetGet(new stdClass()); + $cache->offsetExists(1); +} diff --git a/tests/PHPStan/Integration/data/test-case-extension.php b/tests/PHPStan/Integration/data/test-case-extension.php new file mode 100644 index 0000000000..85b18f8fa9 --- /dev/null +++ b/tests/PHPStan/Integration/data/test-case-extension.php @@ -0,0 +1,45 @@ +testMockMethod(); + } + + public function testPartialMockMethod(): void + { + (new TestTestCase('foo'))->testPartialMockMethod(); + } + + public function testSpyMethod(): void + { + (new TestTestCase('foo'))->testSpyMethod(); + } +} + +// Hypervel: the testing trait declares these mock types in source, and the base TestCase +// already creates the application and always has partialMock(), so upstream's +// createApplication() override and method_exists() guard are omitted. +class TestTestCase extends \Hypervel\Foundation\Testing\TestCase +{ + public function testMockMethod(): void + { + $mock = $this->mock(User::class); + $mock->accounts(); + } + + public function testPartialMockMethod(): void + { + $mock = $this->partialMock(User::class); + $mock->accounts(); + } + + public function testSpyMethod(): void + { + $mock = $this->spy(User::class); + $mock->accounts(); + } +} diff --git a/tests/PHPStan/Integration/data/unknown-relation-model.php b/tests/PHPStan/Integration/data/unknown-relation-model.php new file mode 100644 index 0000000000..69d3f3932f --- /dev/null +++ b/tests/PHPStan/Integration/data/unknown-relation-model.php @@ -0,0 +1,15 @@ + $relation + * @param Builder<\App\User> $query + */ +function existence(Relation $relation, Builder $query): void +{ + $relation->getRelationExistenceCountQuery($query, $query); +} diff --git a/tests/PHPStan/Integration/data/view-string-content.php b/tests/PHPStan/Integration/data/view-string-content.php new file mode 100644 index 0000000000..b8d00b475a --- /dev/null +++ b/tests/PHPStan/Integration/data/view-string-content.php @@ -0,0 +1,35 @@ + 'This is data, not a view'], + htmlString: '

Rendered HTML

', + ); + new Content('home', 'home', 'home', 'home', [], '

Rendered HTML

'); + new Content(); + new Content(view: null, html: null, text: null, markdown: null, htmlString: null); + new Content(view: $dynamic, html: $dynamic, text: $dynamic, markdown: $dynamic); + new Content(view: 'emails.*'); + new Content(with: [], markdown: 'home', text: 'missing-reordered-text', view: 'home'); + + if ($dynamic === null || view()->exists($dynamic)) { + new Content(view: $dynamic, html: $dynamic, text: $dynamic, markdown: $dynamic); + } +} diff --git a/tests/PHPStan/Integration/data/view-string-signatures.php b/tests/PHPStan/Integration/data/view-string-signatures.php new file mode 100644 index 0000000000..5ba230f963 --- /dev/null +++ b/tests/PHPStan/Integration/data/view-string-signatures.php @@ -0,0 +1,78 @@ +make('view-factory-make'); + $factory->make('view-factory-make-does-not-exist'); +} + +function routerView(Router $router): void +{ + $router->view('/uri', 'route-view'); + $router->view('/uri', 'route-view-does-not-exist'); +} + +function mailMessage(MailMessage $message): void +{ + $message->view('emails.mail-message.view'); + $message->view('emails.mail-message.view-does-not-exist'); + $message->markdown('emails.mail-message.markdown'); + $message->markdown('emails.mail-message.markdown-does-not-exist'); +} + +/** @param TestResponse<\Hypervel\Http\Response> $response */ +function testResponseAssertion(TestResponse $response): void +{ + $response->assertViewIs('home'); + $response->assertViewIs('home-does-not-exist'); +} + +class UsesInteractsWithViews +{ + use InteractsWithViews; + + public function good(): void + { + $this->view('home'); + } + + public function bad(): void + { + $this->view('home-does-not-exist'); + } +} + +function viewFacadeMake(): void +{ + View::make('view-factory-make'); + View::make('view-factory-make-does-not-exist'); +} + +function routeFacadeView(): void +{ + Route::view('/uri', 'route-view'); + Route::view('/uri', 'route-view-does-not-exist'); +} + +function mailMessageText(MailMessage $message): void +{ + $message->text('emails.mail-message.view'); + $message->text('emails.mail-message.text-does-not-exist'); +} + +function mailableText(Mailable $mailable): void +{ + $mailable->text('emails.mailable.view'); + $mailable->text('emails.mailable.text-does-not-exist'); +} diff --git a/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php b/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php new file mode 100644 index 0000000000..c986d141d1 --- /dev/null +++ b/tests/PHPStan/Methods/ForwardedBuilderMethodExtensionTest.php @@ -0,0 +1,42 @@ +createMock(ReflectionProvider::class); + $reflectionProvider->expects($this->never())->method('getClass'); + $dependencyTracker = $this->createStub(DeclarationDependencyTracker::class); + $builderHelper = new BuilderHelper( + $reflectionProvider, + $dependencyTracker, + $this->createStub(AttributeClassNameResolver::class), + ); + + new ForwardedBuilderMethodExtension( + $reflectionProvider, + new ModelScopeMethodResolver, + new BuilderMacroResolver($reflectionProvider, [SoftDeletes::class => SoftDeletingMacros::class]), + new BuilderDependencies($dependencyTracker, $builderHelper), + $builderHelper, + $this->createStub(MacroMethodsClassReflectionExtension::class), + ); + } +} diff --git a/tests/PHPStan/Methods/StorageMethodsClassReflectionExtensionTest.php b/tests/PHPStan/Methods/StorageMethodsClassReflectionExtensionTest.php new file mode 100644 index 0000000000..5cce27ecae --- /dev/null +++ b/tests/PHPStan/Methods/StorageMethodsClassReflectionExtensionTest.php @@ -0,0 +1,64 @@ + $managerClass + */ + #[DataProvider('managerProvider')] + public function testFacadeCallsForwardToTheDefaultDiskOnlyThroughTheStockManager(string $managerClass, string $returnType): void + { + $app = new Container; + $app->instance('config', new Repository(['filesystems' => [ + 'default' => 's3', + 'disks' => ['s3' => ['driver' => 's3']], + ]])); + $app->instance('filesystem', new $managerClass($app)); + + $reflectionProvider = $this->createReflectionProvider(); + $runtimeInputs = self::getContainer()->getByType(RuntimeInputs::class); + $diskHelper = new FilesystemDiskHelper($reflectionProvider, self::getContainer()->getByType(ClosureTypeFactory::class), $runtimeInputs); + $diskHelper->setContainer($app); + + $method = (new StorageMethodsClassReflectionExtension($reflectionProvider, $diskHelper, $runtimeInputs)) + ->getMethod($reflectionProvider->getClass(Storage::class), 'assertExists'); + + $this->assertSame($returnType, $method->getVariants()[0]->getReturnType()->describe(VerbosityLevel::precise())); + } + + /** @return iterable, string}> */ + public static function managerProvider(): iterable + { + // The pooled default disk's own assertExists() returns that disk. + yield 'stock manager' => [FilesystemManager::class, 'Hypervel\Filesystem\ClientPooledFilesystem']; + + // A manager whose disk() or __call() is overridden may not reach the default disk, so the + // manager's declared mixins apply. + yield 'overridden disk()' => [SelectingFilesystemManager::class, 'Hypervel\Filesystem\FilesystemAdapter']; + yield 'overridden __call()' => [ForwardingFilesystemManager::class, 'Hypervel\Filesystem\FilesystemAdapter']; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/ModelPropertyDependencyTest.php b/tests/PHPStan/ModelPropertyDependencyTest.php new file mode 100644 index 0000000000..c32d46d28f --- /dev/null +++ b/tests/PHPStan/ModelPropertyDependencyTest.php @@ -0,0 +1,321 @@ +copyApplication(); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($applicationPath . '/config/schema.php', " 'pgsql'];\n"); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } + + /** + * A migration directory that a configured glob starts matching has no tracked path, so its schema + * changes invalidate the result cache. + */ + public function testMigrationDirectoryAGlobStartsMatchingReanalyzesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $parameters = "databaseMigrationsPath:\n - '{$applicationPath}/database/migrations'\n - '{$applicationPath}/modules/*/migrations'"; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath, parameters: $parameters), 1)); + + mkdir($applicationPath . '/modules/profile/migrations', 0777, true); + file_put_contents($applicationPath . '/modules/profile/migrations/2026_01_03_000000_add_nickname_to_users_table.php', <<<'PHP' + string('nickname'); + }); + } + }; + PHP); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath, parameters: $parameters), 0)); + } + + /** + * A schema dump directory that a configured glob starts matching has no tracked path either. + */ + public function testSchemaDumpDirectoryAGlobStartsMatchingReanalyzesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $parameters = "disableMigrationScan: true\nsquashedMigrationsPath:\n - '{$applicationPath}/modules/*/schema'"; + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath, parameters: $parameters), 0)); + + mkdir($applicationPath . '/modules/profile/schema', 0777, true); + file_put_contents( + $applicationPath . '/modules/profile/schema/sqlite-schema.sql', + 'CREATE TABLE IF NOT EXISTS "users" ("id" integer primary key autoincrement not null, "name" varchar not null);' . "\n", + ); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath, parameters: $parameters), 1)); + } + + /** + * PHPStan analyzes the dependents of a changed file again only when its declarations change, but + * a model's casts also follow the body of its casts() method. + */ + public function testChangedCastsMethodBodyReanalyzesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $modelPath = $applicationPath . '/app/Models/User.php'; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($modelPath, str_replace("'name' => 'string'", "'name' => 'array'", file_get_contents($modelPath))); + + $this->assertSame( + ['Usage.php:14 property.notFound', 'Usage.php:22 return.type'], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + } + + /** + * A model's connection can come from a property default in a parent, which is not part of the + * declarations PHPStan compares. + */ + public function testChangedModelParentReanalyzesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $parentPath = $applicationPath . '/app/Models/BaseModel.php'; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($parentPath, str_replace('$connection = null', "\$connection = 'pgsql'", file_get_contents($parentPath))); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } + + /** + * An overriding getConnectionName() can read any config value. + */ + public function testChangedConfigTheModelConnectionReadsReanalyzesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($applicationPath . '/config/models.php', " 'pgsql'];\n"); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } + + /** + * A constant a migration reads is declared outside the migrations, so its file is checked like them. + */ + public function testChangedConstantTheMigrationsReadReanalyzesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $constantsPath = $applicationPath . '/app/Models/Columns.php'; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($constantsPath, str_replace("NAME = 'name'", "NAME = 'nickname'", file_get_contents($constantsPath))); + + // The cast still makes name an attribute. + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } + + /** + * A builder attribute can name its builder by another class's constant, which the model's file does not declare. + */ + public function testChangedConstantABuilderAttributeReadsReanalyzesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $constantsPath = $applicationPath . '/app/Models/BuilderNames.php'; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + // The base builder is not a subclass of itself, so the attribute is ignored and the model creates it. + file_put_contents($constantsPath, str_replace('UserBuilder::class', '\Hypervel\Database\Eloquent\Builder::class', file_get_contents($constantsPath))); + + $this->assertSame( + ['MemberUsage.php:17 method.notFound', 'Usage.php:14 property.notFound'], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + } + + /** + * A guard reads one table's facts to decide what happens to another, so the files those facts were + * read from are checked for every table. + */ + public function testChangedConstantAGuardReadsReanalyzesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $constantsPath = $applicationPath . '/app/Models/FlagColumns.php'; + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($constantsPath, str_replace("NAME = 'name'", "NAME = 'nickname'", file_get_contents($constantsPath))); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + } + + /** + * A migration's parent can override the inherited method the migration calls, changing the schema + * while the file of the method that ran does not change. The migration's own getConnection() reads + * nothing from its parents, so no connection lookup tracks them either. + */ + public function testChangedMigrationParentReanalyzesModelUsage(): void + { + $applicationPath = $this->copyApplication(); + $parentPath = $applicationPath . '/app/Models/UsersMigration.php'; + + file_put_contents($applicationPath . '/app/Models/NicknameMigration.php', <<<'PHP' + string('nickname'); + }); + } + } + PHP); + file_put_contents($parentPath, <<<'PHP' + addNickname(); + } + }; + PHP); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + + file_put_contents($parentPath, str_replace("{\n}", "{\n protected function addNickname(): void\n {\n }\n}", file_get_contents($parentPath))); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + } + + /** + * Calls through a model or a relation depend on the model, not on its builder, and a builder's + * passthru methods come from a property default, which is not part of its declarations. + * + * PHPStan gives a class's tracked inputs to every file depending on it, so each route's calls name + * their own classes, and one route's inputs cannot cover a gap in another's. + */ + public function testChangedBuilderReanalyzesForwardedCalls(): void + { + $applicationPath = $this->copyApplication(); + $builderPath = $applicationPath . '/app/Models/UserBuilder.php'; + $builder = file_get_contents($builderPath); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + // A passthru edit alone, since a declaration change reanalyzes the builder's direct callers anyway. + file_put_contents($builderPath, str_replace("['exists']", '[]', $builder)); + + $this->assertSame( + [ + 'BuilderUsage.php:17 return.type', + 'ModelUsage.php:17 return.type', + 'RelationUsage.php:17 return.type', + 'Usage.php:14 property.notFound', + ], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + + file_put_contents($builderPath, str_replace('function published()', 'function unpublished()', $builder)); + + $this->assertSame( + ['MemberUsage.php:17 method.notFound', 'RelationUsage.php:25 method.notFound', 'Usage.php:14 property.notFound'], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + + file_put_contents($builderPath, $builder); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + } + + /** + * Only the builder's getQuery() signature names its query class. + */ + public function testChangedQueryClassReanalyzesBuilderUsage(): void + { + $applicationPath = $this->copyApplication(); + $queryPath = $applicationPath . '/app/Models/UserQuery.php'; + $query = file_get_contents($queryPath); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + + // Not a where method, which the base query would handle as a dynamic where. + file_put_contents($queryPath, str_replace('function onlyActive()', 'function onlyInactive()', $query)); + + $this->assertSame( + ['BuilderUsage.php:25 method.notFound', 'Usage.php:14 property.notFound'], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + + file_put_contents($queryPath, $query); + + $this->assertSame(['Usage.php:14 property.notFound'], $this->diagnostics($this->analyse($applicationPath), 1)); + } +} diff --git a/tests/PHPStan/QueueJobDependencyTest.php b/tests/PHPStan/QueueJobDependencyTest.php new file mode 100644 index 0000000000..f2664311b6 --- /dev/null +++ b/tests/PHPStan/QueueJobDependencyTest.php @@ -0,0 +1,83 @@ +copyApplication(); + $jobsPath = $applicationPath . '/app/Jobs'; + + // The application's default database connection is known, unlike the analyzer's Testbench + // application's, so Usage.php:30 shows that usingConnection() replaces it. + $this->assertSame( + [ + 'ImportJob.php:7 hypervel.batchableJobChecksCancellation', + 'Usage.php:14 hypervel.dispatchInTransactionAfterCommit', + 'Usage.php:15 hypervel.dispatchInTransactionAfterCommit', + 'Usage.php:16 hypervel.dispatchInTransactionAfterCommit', + 'Usage.php:24 hypervel.dispatchInTransactionAfterCommit', + ], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + + file_put_contents($jobsPath . '/ConstructedJob.php', str_replace("{\n }", "{\n \$this->afterCommit();\n }", file_get_contents($jobsPath . '/ConstructedJob.php'))); + + $this->assertSame( + [ + 'ImportJob.php:7 hypervel.batchableJobChecksCancellation', + 'Usage.php:14 hypervel.dispatchInTransactionAfterCommit', + 'Usage.php:16 hypervel.dispatchInTransactionAfterCommit', + 'Usage.php:24 hypervel.dispatchInTransactionAfterCommit', + ], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + + file_put_contents($jobsPath . '/BaseJob.php', str_replace('$afterCommit = null', '$afterCommit = true', file_get_contents($jobsPath . '/BaseJob.php'))); + + $this->assertSame( + [ + 'ImportJob.php:7 hypervel.batchableJobChecksCancellation', + 'Usage.php:14 hypervel.dispatchInTransactionAfterCommit', + 'Usage.php:24 hypervel.dispatchInTransactionAfterCommit', + ], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + + file_put_contents($jobsPath . '/Connections.php', str_replace("'before-commit'", "'sync'", file_get_contents($jobsPath . '/Connections.php'))); + + $this->assertSame( + ['ImportJob.php:7 hypervel.batchableJobChecksCancellation', 'Usage.php:24 hypervel.dispatchInTransactionAfterCommit'], + $this->diagnostics($this->analyse($applicationPath), 1), + ); + } + + /** + * A batchable job can inherit the method that checks for cancellation, and a method body is not + * part of the declarations PHPStan compares. + */ + public function testChangedInheritedHandleReanalyzesBatchableJob(): void + { + $applicationPath = $this->copyApplication(); + $batchJobPath = $applicationPath . '/app/Jobs/BatchJob.php'; + + $this->assertContains('ImportJob.php:7 hypervel.batchableJobChecksCancellation', $this->diagnostics($this->analyse($applicationPath), 1)); + + file_put_contents($batchJobPath, str_replace("{\n }", "{\n if (\$this->batch()?->cancelled()) {\n return;\n }\n }", file_get_contents($batchJobPath))); + + $this->assertNotContains('ImportJob.php:7 hypervel.batchableJobChecksCancellation', $this->diagnostics($this->analyse($applicationPath), 1)); + } +} diff --git a/tests/PHPStan/Reflection/MacroMethodsClassReflectionExtensionTest.php b/tests/PHPStan/Reflection/MacroMethodsClassReflectionExtensionTest.php new file mode 100644 index 0000000000..a4d1bc9a06 --- /dev/null +++ b/tests/PHPStan/Reflection/MacroMethodsClassReflectionExtensionTest.php @@ -0,0 +1,174 @@ +reflectionProvider = $this->createReflectionProvider(); + $this->reflectionExtension = new MacroMethodsClassReflectionExtension( + $this->reflectionProvider, + self::getContainer()->getByType(ClosureTypeFactory::class), + self::getContainer()->getByType(WithHeadExtension::class), + self::getContainer()->getByType(AuthGuardHelper::class), + self::getContainer()->getByType(RuntimeInputs::class), + ); + } + + #[Test] + #[DataProvider('methodAndClassProvider')] + public function itCanFindMacrosOnAClass(string $class, string $methodName): void + { + $requestClass = $this->reflectionProvider->getClass($class); + + $this->assertTrue($this->reflectionExtension->hasMethod($requestClass, $methodName)); + } + + public static function methodAndClassProvider(): Generator + { + // Hypervel: Request's @method tags type its validation and signature macros; see taggedMethodProvider(). + yield [Collection::class, 'customCollectionMacro']; + yield [MacroHost::class, 'invokableMacro']; + } + + #[Test] + #[DataProvider('taggedMethodProvider')] + public function itLeavesTaggedMacrosToTheirTags(string $class, string $methodName): void + { + $this->assertFalse($this->reflectionExtension->hasMethod($this->reflectionProvider->getClass($class), $methodName)); + } + + public static function taggedMethodProvider(): Generator + { + yield [Request::class, 'validate']; + yield [Request::class, 'validateWithBag']; + yield [Request::class, 'hasValidSignature']; + yield [Request::class, 'hasValidRelativeSignature']; + } + + #[Test] + #[DataProvider('methodAndThrowTypeProvider')] + public function itCanSetThrowTypeForMacros(string $class, string $methodName, string $exceptionClass): void + { + // Hypervel: the @method tag types Request's macros, so MacroThrowTypeExtension reads their exceptions here. + $throwType = $this->reflectionExtension->getThrowType($this->reflectionProvider->getClass($class), $methodName); + + $this->assertInstanceOf(ObjectType::class, $throwType); + $this->assertSame($exceptionClass, $throwType->getClassName()); + } + + public static function methodAndThrowTypeProvider(): Generator + { + yield [Request::class, 'validate', ValidationException::class]; + yield [Request::class, 'validateWithBag', ValidationException::class]; + } + + #[Test] + public function itOnlyGivesRequestMacrosTheValidationException(): void + { + $this->assertNull($this->reflectionExtension->getThrowType($this->reflectionProvider->getClass(MacroHost::class), 'validate')); + } + + #[Test] + public function itForwardsAGuardMacroThatEveryConfiguredGuardCanCall(): void + { + $extension = $this->createGuardMacroExtension(); + $manager = $this->reflectionProvider->getClass(AuthManager::class); + $facade = $this->reflectionProvider->getClass(Auth::class); + + // RequestGuard registers guardMacro() and NativeCallGuard declares it, so either current guard has it. + $this->assertTrue($extension->hasMethod($manager, 'guardMacro')); + $this->assertTrue($extension->hasMethod($facade, 'guardMacro')); + + $method = $extension->getMethod($manager, 'guardMacro'); + + // AuthManager has no __callStatic(), unlike the facade. + $this->assertFalse($method->isStatic()); + $this->assertTrue($extension->getMethod($facade, 'guardMacro')->isStatic()); + $this->assertSame('int|string', $method->getVariants()[0]->getReturnType()->describe(VerbosityLevel::precise())); + + // NativeCallGuard has no requestGuardMacro(). + $this->assertFalse($extension->hasMethod($manager, 'requestGuardMacro')); + } + + #[Test] + public function itKeepsTheCallHandlerOfAnAuthManagerSubclass(): void + { + $extension = $this->createGuardMacroExtension(); + $manager = $this->reflectionProvider->getClass(MacroableAuthManager::class); + + MacroableAuthManager::macro('ownMacro', static fn (): string => ''); + + $this->assertTrue($extension->hasMethod($manager, 'ownMacro')); + $this->assertFalse($extension->hasMethod($manager, 'guardMacro')); + } + + /** + * Create the extension for an auth manager with a request guard and a custom guard. + */ + private function createGuardMacroExtension(): MacroMethodsClassReflectionExtension + { + $app = new Container; + $app->instance('config', new Repository(['auth' => ['guards' => [ + 'request' => ['driver' => 'request'], + 'native' => ['driver' => 'native'], + ]]])); + + $auth = new AuthManager($app); + $auth->viaRequest('request', static fn () => null); + $auth->extend('native', static fn (): NativeCallGuard => new NativeCallGuard); + $app->instance('auth', $auth); + + $closureTypeFactory = self::getContainer()->getByType(ClosureTypeFactory::class); + $runtimeInputs = self::getContainer()->getByType(RuntimeInputs::class); + $guards = new AuthGuardHelper($this->reflectionProvider, $closureTypeFactory, $runtimeInputs); + $guards->setContainer($app); + + return new MacroMethodsClassReflectionExtension( + $this->reflectionProvider, + $closureTypeFactory, + self::getContainer()->getByType(WithHeadExtension::class), + $guards, + $runtimeInputs, + ); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + // Hypervel: the test bootstrap registers the untagged fixture macros. + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Reflection/ManagersMethodsExtensionTest.php b/tests/PHPStan/Reflection/ManagersMethodsExtensionTest.php new file mode 100644 index 0000000000..4217863c8c --- /dev/null +++ b/tests/PHPStan/Reflection/ManagersMethodsExtensionTest.php @@ -0,0 +1,28 @@ +createReflectionProvider()->getClass(ExportManager::class); + + // Manager has no __callStatic(), so even the driver's static method is called on a manager instance. + $this->assertFalse($manager->getMethod('make', new OutOfClassScope)->isStatic()); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Reflection/RedirectResponseMethodsClassReflectionExtensionTest.php b/tests/PHPStan/Reflection/RedirectResponseMethodsClassReflectionExtensionTest.php new file mode 100644 index 0000000000..57853611c7 --- /dev/null +++ b/tests/PHPStan/Reflection/RedirectResponseMethodsClassReflectionExtensionTest.php @@ -0,0 +1,86 @@ +reflectionProvider = $this->createReflectionProvider(); + $this->reflectionExtension = new RedirectResponseMethodsClassReflectionExtension; + } + + #[Test] + #[DataProvider('greenMethodProvider')] + public function itWillFindMethodsStartingWithWith(string $methodName): void + { + $requestClass = $this->reflectionProvider->getClass(RedirectResponse::class); + + $this->assertTrue($this->reflectionExtension->hasMethod($requestClass, $methodName)); + } + + #[Test] + #[DataProvider('redMethodProvider')] + public function itWillNotFindMethods(string $methodName): void + { + $requestClass = $this->reflectionProvider->getClass(RedirectResponse::class); + + $this->assertFalse($this->reflectionExtension->hasMethod($requestClass, $methodName)); + } + + /** @return iterable */ + public static function redMethodProvider(): iterable + { + yield ['non-existent']; + yield ['aWith']; + yield ['WithFoo']; + } + + #[Test] + #[DataProvider('greenMethodProvider')] + public function itWillHaveCorrectMethodReflection(string $methodName): void + { + $requestClass = $this->reflectionProvider->getClass(RedirectResponse::class); + $methodReflection = $this->reflectionExtension->getMethod($requestClass, $methodName); + $parametersAcceptor = $methodReflection->getVariants()[0]; + + $this->assertSame($methodName, $methodReflection->getName()); + $this->assertSame($requestClass, $methodReflection->getDeclaringClass()); + $this->assertFalse($methodReflection->isStatic()); + $this->assertFalse($methodReflection->isPrivate()); + $this->assertTrue($methodReflection->isPublic()); + $this->assertTrue($methodReflection->hasSideEffects()->yes()); + $this->assertCount(1, $parametersAcceptor->getParameters()); + $this->assertSame('mixed', $parametersAcceptor->getParameters()[0]->getType()->describe(VerbosityLevel::value())); + $this->assertSame(RedirectResponse::class, $parametersAcceptor->getReturnType()->describe(VerbosityLevel::value())); + } + + /** @return iterable */ + public static function greenMethodProvider(): iterable + { + yield ['withFoo']; + yield ['withFooAndBar']; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../Rules/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Reflection/ReflectionHelperTest.php b/tests/PHPStan/Reflection/ReflectionHelperTest.php new file mode 100644 index 0000000000..1fd002893f --- /dev/null +++ b/tests/PHPStan/Reflection/ReflectionHelperTest.php @@ -0,0 +1,37 @@ +createReflectionProvider()->getClass(MixinHost::class); + + // DateInterval declares format() and the public $y; ArrayObject declares neither. + $dateInterval = $host->withTypes([new ObjectType(DateInterval::class)]); + $arrayObject = $host->withTypes([new ObjectType(ArrayObject::class)]); + + $this->assertTrue(ReflectionHelper::hasMethodTag($dateInterval, 'format')); + $this->assertTrue(ReflectionHelper::hasPropertyTag($dateInterval, 'y')); + $this->assertFalse(ReflectionHelper::hasMethodTag($arrayObject, 'format')); + $this->assertFalse(ReflectionHelper::hasPropertyTag($arrayObject, 'y')); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/ResultCacheTestCase.php b/tests/PHPStan/ResultCacheTestCase.php new file mode 100644 index 0000000000..cd48d4072d --- /dev/null +++ b/tests/PHPStan/ResultCacheTestCase.php @@ -0,0 +1,161 @@ +tempDir = ParallelTesting::tempDir('PHPStan' . class_basename(static::class)); + (new Filesystem)->deleteDirectory($this->tempDir); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + /** + * Copy the fixture application to the temporary directory. + */ + protected function copyApplication(): string + { + $applicationPath = $this->tempDir . '/application'; + (new Filesystem)->copyDirectory(__DIR__ . "/Fixtures/{$this->fixture}/application", $applicationPath); + mkdir($applicationPath . '/bootstrap/cache'); + + return $applicationPath; + } + + /** + * Run PHPStan from the application over its app directory, with the result and migration caches + * and the opt-in rules the fixtures exercise on, plus any extra top-level configuration and parameters. + * + * PHPStan uses its result cache only when it analyzes a directory, and reports whether it reused + * the cache on standard error at the very verbose level. The application's autoloader is the + * autoload file, so classes PHPStan registers as services load before it builds its container. + * The explicit memory limit keeps the run independent of php.ini, whose default is often 128M. + * Reusing collected-data rule errors is the bleeding-edge toggle that skips those rules on a warm + * run while their collected data and tracked dependencies are unchanged. + * + * @param array $environment variables to set, or to unset with false, over the test's own + */ + protected function analyse( + string $applicationPath, + string $configuration = '', + bool $reuseCollectedDataRuleErrors = false, + array $environment = [], + string $parameters = '', + ): Process { + $repositoryPath = dirname(__DIR__, 2); + $reuse = $reuseCollectedDataRuleErrors ? 'true' : 'false'; + $parameters = str_replace("\n", "\n ", $parameters); + + file_put_contents($applicationPath . '/phpstan.neon', <<run(); + + return $process; + } + + /** + * Get the file diagnostics as "file:line identifier" entries. + * + * @return list + */ + protected function diagnostics(Process $process, int $exitCode): array + { + $this->assertSame($exitCode, $process->getExitCode(), $process->getErrorOutput() . $process->getOutput()); + + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([], $result['errors']); + + $diagnostics = []; + + foreach ($result['files'] as $file => $errors) { + foreach ($errors['messages'] as $message) { + $diagnostics[] = basename($file) . ":{$message['line']} {$message['identifier']}"; + } + } + + sort($diagnostics); + + return $diagnostics; + } + + /** + * Replace text that must be in the file. + */ + protected function edit(string $path, string $search, string $replace): void + { + $contents = (string) file_get_contents($path); + + $this->assertStringContainsString($search, $contents); + + file_put_contents($path, str_replace($search, $replace, $contents)); + } + + /** + * Assert how many files a warm run reanalyzed. + */ + protected function assertReanalyzed(int $count, Process $process): void + { + $this->assertStringContainsString( + "Result cache restored. {$count} " . ($count === 1 ? 'file' : 'files') . ' will be reanalysed.', + $process->getErrorOutput(), + ); + } +} diff --git a/tests/PHPStan/Rules/ArrKeyExistenceRuleTest.php b/tests/PHPStan/Rules/ArrKeyExistenceRuleTest.php new file mode 100644 index 0000000000..0c26ced927 --- /dev/null +++ b/tests/PHPStan/Rules/ArrKeyExistenceRuleTest.php @@ -0,0 +1,48 @@ + */ +class ArrKeyExistenceRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new CompositeRule([ + new ArrKeyExistenceRule, + self::getContainer()->getByType(NonexistentOffsetInArrayDimFetchRule::class), + ]); + } + + public function testRule(): void + { + $shape = 'array{foo: string, bar?: int, nested: array{deep: bool}, word: string}'; + + $this->analyse([__DIR__ . '/data/arr-key-existence.php'], [ + ["Offset 'bar' does not exist on array{foo: '123'}.", 15], + ["Offset 'missing' does not exist on " . $shape . '.', 45], + ["Offset 'missing' does not exist on array{deep: bool}.", 46], + ["Offset '0' does not exist on string.", 47], + ["Offset 'missing' does not exist on " . $shape . '.', 48], + ["Offset 'other' does not exist on " . $shape . '.', 48], + ["Offset 'missing' does not exist on array{foo: string}|null.", 49], + ["Offset 'missing' does not exist on " . $shape . '.', 50], + ["Offset 'missing' does not exist on " . $shape . '.', 51], + ["Offset 'missing' does not exist on " . $shape . '.', 52], + ["Offset 'missing' does not exist on " . $shape . '.', 53], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/CheckDispatchArgumentTypesCompatibleWithClassConstructorRuleTest.php b/tests/PHPStan/Rules/CheckDispatchArgumentTypesCompatibleWithClassConstructorRuleTest.php new file mode 100644 index 0000000000..7238cabb57 --- /dev/null +++ b/tests/PHPStan/Rules/CheckDispatchArgumentTypesCompatibleWithClassConstructorRuleTest.php @@ -0,0 +1,90 @@ + */ +class CheckDispatchArgumentTypesCompatibleWithClassConstructorRuleTest extends RuleTestCase +{ + private string $dispatchableClass; + + protected function getRule(): Rule + { + $broker = $this->createReflectionProvider(); + + return new CheckDispatchArgumentTypesCompatibleWithClassConstructorRule( + $broker, + self::getContainer()->getByType(FunctionCallParametersCheck::class), + $this->dispatchableClass, + ); + } + + public function testJobDispatch(): void + { + $this->dispatchableClass = Dispatchable::class; + + $this->analyse([__DIR__ . '/data/job-dispatch.php'], [ + ['Job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor invoked with 0 parameters in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatch(), 2 required.', 5], + ['Job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor invoked with 0 parameters in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchSync(), 2 required.', 6], + // REMOVED: dispatchNow() cases. Hypervel's Dispatchable has no dispatchNow() method. + ['Job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor invoked with 0 parameters in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchAfterResponse(), 2 required.', 7], + ['Job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor invoked with 1 parameter in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatch(), 2 required.', 9], + ['Parameter #1 $foo of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatch(), int given.', 10], + ['Parameter #2 $bar of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatch(), string given.', 10], + ['Parameter #1 $foo of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatch(), true given.', 11], + ['Parameter #2 $bar of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatch(), false given.', 11], + ['Job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor invoked with 1 parameter in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchSync(), 2 required.', 13], + ['Parameter #1 $foo of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchSync(), int given.', 14], + ['Parameter #2 $bar of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchSync(), string given.', 14], + ['Parameter #1 $foo of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchSync(), true given.', 15], + ['Parameter #2 $bar of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchSync(), false given.', 15], + ['Job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor invoked with 1 parameter in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchAfterResponse(), 2 required.', 17], + ['Parameter #1 $foo of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchAfterResponse(), int given.', 18], + ['Parameter #2 $bar of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchAfterResponse(), string given.', 18], + ['Parameter #1 $foo of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchAfterResponse(), true given.', 19], + ['Parameter #2 $bar of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchAfterResponse(), false given.', 19], + ['Parameter #1 $foo of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchIf(), int given.', 21], + ['Parameter #2 $bar of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchIf(), string given.', 21], + ['Parameter #1 $foo of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchUnless(), int given.', 22], + ['Job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJobWithoutConstructor does not have a constructor and must be dispatched without any parameters.', 25], + ['Job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJobWithoutConstructor does not have a constructor and must be dispatched without any parameters.', 27], + ['Job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJobWithoutConstructor does not have a constructor and must be dispatched without any parameters.', 29], + ['Job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJobWithoutConstructor does not have a constructor and must be dispatched without any parameters.', 32], + ['Parameter #1 $foo of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchIf(), int given.', 36], + ['Parameter #2 $bar of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatchIf(), string given.', 36], + ['Missing parameter $bar (int) in call to Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor.', 37], + ['Parameter #1 $foo of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatch(), int given.', 42], + ['Parameter #2 $bar of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelJob constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelJob::dispatch(), string given.', 42], + ['Parameter #1 $bar of job class Hypervel\Tests\PHPStan\Rules\Data\HypervelChildJob constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelChildJob::dispatch(), string given.', 70], + ]); + } + + public function testEventDispatch(): void + { + $this->dispatchableClass = EventDispatchable::class; + + $this->analyse([__DIR__ . '/data/event-dispatch.php'], [ + ['Event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent constructor invoked with 0 parameters in Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent::dispatch(), 2 required.', 5], + ['Event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent constructor invoked with 1 parameter in Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent::dispatch(), 2 required.', 7], + ['Parameter #1 $foo of event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent::dispatch(), int given.', 8], + ['Parameter #2 $bar of event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent::dispatch(), string given.', 8], + ['Parameter #1 $foo of event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent::dispatch(), true given.', 9], + ['Parameter #2 $bar of event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent::dispatch(), false given.', 9], + ['Parameter #1 $foo of event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent::dispatchIf(), int given.', 11], + ['Parameter #2 $bar of event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent::dispatchIf(), string given.', 11], + ['Parameter #1 $foo of event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent constructor expects string in Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent::dispatchUnless(), int given.', 12], + ['Event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEventWithoutConstructor does not have a constructor and must be dispatched without any parameters.', 15], + ['Event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEventWithoutConstructor does not have a constructor and must be dispatched without any parameters.', 18], + ['Event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent constructor invoked with 1 parameter in Hypervel\Tests\PHPStan\Rules\Data\HypervelEvent::broadcast(), 2 required.', 21], + ['Parameter #1 $count of event class Hypervel\Tests\PHPStan\Rules\Data\HypervelEventWithReference constructor expects int in Hypervel\Tests\PHPStan\Rules\Data\HypervelEventWithReference::dispatch(), string given.', 25], + ]); + } +} diff --git a/tests/PHPStan/Rules/ConfigCollectionRuleTest.php b/tests/PHPStan/Rules/ConfigCollectionRuleTest.php new file mode 100644 index 0000000000..9b8c27414a --- /dev/null +++ b/tests/PHPStan/Rules/ConfigCollectionRuleTest.php @@ -0,0 +1,48 @@ + */ +class ConfigCollectionRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(ConfigCollectionRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/config-collection-rule.php'], [ + [ + "Config key 'test.foo' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", + 12, + ], + [ + "Config key 'test.foo' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", + 19, + ], + [ + "Config key 'test.foo' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", + 20, + ], + [ + "Config key 'test.missing' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", + 26, + ], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../Type/data/config-with-config-paths.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/ConfigTypeRuleTest.php b/tests/PHPStan/Rules/ConfigTypeRuleTest.php new file mode 100644 index 0000000000..3b6fd8c363 --- /dev/null +++ b/tests/PHPStan/Rules/ConfigTypeRuleTest.php @@ -0,0 +1,37 @@ + */ +class ConfigTypeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(ConfigTypeRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/config-type-rule.php'], [ + ["Config key 'test.foo' is not an integer, so integer() throws. Correct the config value or use the getter that matches its type.", 14], + ["Config key 'test.foo' is not an array, so array() throws. Correct the config value or use the getter that matches its type.", 16], + ["Config key 'auth.defaults.guard' is not a float, so float() throws. Correct the config value or use the getter that matches its type.", 17], + ["Config key 'test.missing' is not a boolean, so boolean() throws. Correct the config value or use the getter that matches its type.", 31], + ["Config key 'test.foo' is not an integer, so integer() throws. Correct the config value or use the getter that matches its type.", 40], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../Type/data/config-with-config-paths.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/CancellationPrecedenceRuleTest.php b/tests/PHPStan/Rules/Coroutine/CancellationPrecedenceRuleTest.php new file mode 100644 index 0000000000..ff8655f668 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/CancellationPrecedenceRuleTest.php @@ -0,0 +1,38 @@ + */ +class CancellationPrecedenceRuleTest extends RuleTestCase +{ + private const string MESSAGE = '%1$s ??= $exception keeps the first failure even when a later one carries this coroutine\'s cancellation. Use %1$s = Coroutine::preferCancellation(%1$s, $exception).'; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(CancellationPrecedenceRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/cancellation-precedence.php'], [ + [sprintf(self::MESSAGE, '$failure'), 26], + [sprintf(self::MESSAGE, '$failure'), 41], + [sprintf(self::MESSAGE, '$this->failure'), 54], + [sprintf(self::MESSAGE, '$failure'), 73], + [sprintf(self::MESSAGE, '$failure'), 146], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/ConfiguredConcurrencyDriverTest.php b/tests/PHPStan/Rules/Coroutine/ConfiguredConcurrencyDriverTest.php new file mode 100644 index 0000000000..5018e8a4d1 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/ConfiguredConcurrencyDriverTest.php @@ -0,0 +1,30 @@ + */ +class ConfiguredConcurrencyDriverTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoCoroutinesInTransactionsRule::class); + } + + public function testConfiguredCoroutineDriverStartsCoroutines(): void + { + $this->analyse([__DIR__ . '/data/concurrency-run.php'], [ + [NoCoroutinesInTransactionsRuleTest::MESSAGE, 15], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/configured-concurrency.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/ExactCancellationCatchRuleTest.php b/tests/PHPStan/Rules/Coroutine/ExactCancellationCatchRuleTest.php new file mode 100644 index 0000000000..01dbcfb554 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/ExactCancellationCatchRuleTest.php @@ -0,0 +1,41 @@ + */ +class ExactCancellationCatchRuleTest extends RuleTestCase +{ + private const string MESSAGE = "Catching CanceledException misses cancellation carried by another exception, which the catch on line %d handles as an ordinary failure. Check Coroutine::causedByCancellation() in that catch instead.\n 💡 A cleanup or callback failure in the try block can carry the cancellation as its previous exception."; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(ExactCancellationCatchRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/exact-cancellation-catches.php'], [ + [sprintf(self::MESSAGE, 22), 20], + [sprintf(self::MESSAGE, 37), 35], + [sprintf(self::MESSAGE, 54), 52], + [sprintf(self::MESSAGE, 74), 72], + [sprintf(self::MESSAGE, 85), 83], + [sprintf(self::MESSAGE, 186), 184], + [sprintf(self::MESSAGE, 201), 199], + [sprintf(self::MESSAGE, 214), 212], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/ExactCancellationCheckRuleTest.php b/tests/PHPStan/Rules/Coroutine/ExactCancellationCheckRuleTest.php new file mode 100644 index 0000000000..a455f57ef6 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/ExactCancellationCheckRuleTest.php @@ -0,0 +1,40 @@ + */ +class ExactCancellationCheckRuleTest extends RuleTestCase +{ + private const string MESSAGE = 'Checking instanceof CanceledException misses cancellation carried by another exception. Use Coroutine::causedByCancellation(), or Coroutine::preferCancellation() to choose between failures.'; + + private const string CARRIED_TIP = 'A cleanup or callback failure can carry the cancellation as its previous exception.'; + + private const string GUARD_TIP = 'Coroutine::causedByCancellation() takes a Throwable, so check "instanceof Throwable" first for a value that may be null or not an exception.'; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(ExactCancellationCheckRule::class); + } + + public function testRule(): void + { + $guarded = self::MESSAGE . "\n 💡 • " . self::CARRIED_TIP . "\n• " . self::GUARD_TIP; + + $this->analyse([__DIR__ . '/data/exact-cancellation-checks.php'], [ + [self::MESSAGE . "\n 💡 " . self::CARRIED_TIP, 14], + [$guarded, 19], + [$guarded, 24], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/NoCoroutinesInTransactionsRuleTest.php b/tests/PHPStan/Rules/Coroutine/NoCoroutinesInTransactionsRuleTest.php new file mode 100644 index 0000000000..a77792a548 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/NoCoroutinesInTransactionsRuleTest.php @@ -0,0 +1,78 @@ + */ +class NoCoroutinesInTransactionsRuleTest extends RuleTestCase +{ + public const string MESSAGE = "A coroutine started inside a database transaction runs outside it: the child borrows its own pooled connection.\n 💡 Start the coroutine after the transaction commits, or run the work in the transaction's own coroutine."; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoCoroutinesInTransactionsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/coroutines-in-transactions.php'], [ + [self::MESSAGE, 23], + [self::MESSAGE, 24], + [self::MESSAGE, 25], + [self::MESSAGE, 26], + [self::MESSAGE, 27], + [self::MESSAGE, 28], + [self::MESSAGE, 41], + [self::MESSAGE, 48], + [self::MESSAGE, 51], + [self::MESSAGE, 52], + [self::MESSAGE, 67], + [self::MESSAGE, 74], + [self::MESSAGE, 79], + [self::MESSAGE, 87], + [self::MESSAGE, 90], + [self::MESSAGE, 97], + ]); + } + + public function testCustomCoroutineDriverIsUnknown(): void + { + $manager = Container::getInstance()->make(ConcurrencyManager::class); + $manager->extend('coroutine', fn (): SyncDriver => new SyncDriver); + + try { + $this->analyse([__DIR__ . '/data/concurrency-run.php'], []); + } finally { + (function (): void { + unset($this->customCreators['coroutine']); + })->call($manager); + } + } + + public function testExtendedManagerIsUnknown(): void + { + $container = Container::getInstance(); + $manager = $container->make(ConcurrencyManager::class); + $container->instance(ConcurrencyManager::class, new ExtendedConcurrencyManager($container)); + + try { + $this->analyse([__DIR__ . '/data/concurrency-run.php'], []); + } finally { + $container->instance(ConcurrencyManager::class, $manager); + } + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/NoRawSleepCallsRuleTest.php b/tests/PHPStan/Rules/Coroutine/NoRawSleepCallsRuleTest.php new file mode 100644 index 0000000000..c091301cf0 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/NoRawSleepCallsRuleTest.php @@ -0,0 +1,33 @@ + */ +class NoRawSleepCallsRuleTest extends RuleTestCase +{ + private const string TIP = "\n 💡 Keep the native call only where real time must pass, such as polling an external process."; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoRawSleepCallsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/raw-sleep.php'], [ + ['Call Sleep::sleep() instead of sleep(), so tests can fake the delay.' . self::TIP, 14], + ['Call Sleep::usleep() instead of usleep(), so tests can fake the delay.' . self::TIP, 15], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/WorkerStateRuleTest.php b/tests/PHPStan/Rules/Coroutine/WorkerStateRuleTest.php new file mode 100644 index 0000000000..45be05d956 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/WorkerStateRuleTest.php @@ -0,0 +1,331 @@ + */ +class WorkerStateRuleTest extends RuleTestCase +{ + private const string NAMESPACE = 'Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\\'; + + private const string SHARED_STATE = 'Property %1$s::$%2$s keeps %3$s, but the container shares %1$s for the whole worker.'; + + private const string SHARED_STATE_TIP = 'Pass request values to the methods that use them or keep them in CoroutineContext. If the whole object belongs to one request, mark it #[Scoped].'; + + private const string SHARED_WRITE = '%1$s::%2$s() writes $this->%3$s while handling a request, but the container shares %1$s across concurrent requests.'; + + private const string SHARED_WRITE_TIP = 'Keep per-request values in local variables or CoroutineContext.'; + + private const string STATIC_STATE = 'Static property %s::$%s keeps %s, which the whole worker then shares.'; + + private const string STATIC_WRITE = 'Static property %s::$%s is written %s, but static properties are shared by the whole worker.'; + + private const string STATIC_TIP = 'Keep per-request values in CoroutineContext, and set static state only during boot.'; + + private const string CACHE = '%s %s::$%s is keyed by request or user data, so it can grow for the life of the worker.'; + + private const string CACHE_TIP = 'Use a cache store with an expiry, or keep per-request values in CoroutineContext.'; + + private const string CONFIG = 'Configuration is changed %s, which changes it for the whole worker.'; + + private const string CONFIG_TIP = 'Set configuration during boot, and keep per-request values in CoroutineContext.'; + + private const string REQUEST = 'while handling a request'; + + private const string JOB = 'while running a job'; + + /** + * The checkWorkerSharedState, checkStaticStateMutation, checkUnboundedWorkerCaches and + * noRuntimeConfigMutation switches. + * + * @var array{bool, bool, bool, bool} + */ + private array $switches = [true, true, true, true]; + + protected function getRule(): Rule + { + $rule = new WorkerStateRule($this->createReflectionProvider(), $this->createHelper(), self::getContainer()->getByType(RuntimeInputs::class)); + $rule->setContainer($this->createApplication()); + + return $rule; + } + + /** @return array> */ + protected function getCollectors(): array + { + $helper = $this->createHelper(); + + return [ + new WorkerStateWriteCollector($helper), + new WorkerStateCallCollector($this->createReflectionProvider(), $helper), + new WorkerStateClassCollector($helper), + new WorkerStateParameterCollector($helper), + new WorkerStateConfigOffsetCollector($helper), + ]; + } + + /** + * @param array{bool, bool, bool, bool} $switches + * @param list $groups + */ + #[DataProvider('switchProvider')] + public function testRule(array $switches, array $groups): void + { + $this->switches = $switches; + $errors = self::getExpectedErrors(); + + $this->analyse( + [__DIR__ . '/data/worker-state.php'], + array_merge([], ...array_map(static fn (string $group): array => $errors[$group], $groups)), + ); + } + + /** + * @return iterable}> + */ + public static function switchProvider(): iterable + { + yield 'all checks' => [[true, true, true, true], ['shared', 'static', 'cache', 'config']]; + yield 'without shared state' => [[false, true, true, true], ['static', 'cache', 'config']]; + yield 'without static state' => [[true, false, true, true], ['shared', 'cache', 'config']]; + yield 'without unbounded caches' => [[true, true, false, true], ['shared', 'static', 'static cache write', 'config']]; + yield 'without config' => [[true, true, true, false], ['shared', 'static', 'cache']]; + yield 'no checks' => [[false, false, false, false], []]; + } + + public function testRouteClosures(): void + { + $static = [sprintf(self::STATIC_WRITE, self::NAMESPACE . 'CountingMiddleware', 'seen', self::REQUEST), self::STATIC_TIP]; + + $this->analyse([__DIR__ . '/data/worker-state-routes.php'], [ + [$static[0], 13, $static[1]], + [sprintf(self::CONFIG, self::REQUEST), 14, self::CONFIG_TIP], + [$static[0], 22, $static[1]], + [$static[0], 25, $static[1]], + [$static[0], 29, $static[1]], + [$static[0], 41, $static[1]], + [sprintf(self::CONFIG, self::REQUEST), 47, self::CONFIG_TIP], + [$static[0], 54, $static[1]], + ]); + } + + /** + * Route actions at the same position of different files are different functions. + */ + public function testRouteActionsAreKeyedByFile(): void + { + $this->analyse([__DIR__ . '/data/worker-state-route-reassigned.php', __DIR__ . '/data/worker-state-route-written.php'], [ + [sprintf(self::CONFIG, self::REQUEST), 11, self::CONFIG_TIP], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } + + /** + * Get the expected errors for worker-state.php, grouped by the switch that reports them. + * + * @return array> + */ + private static function getExpectedErrors(): array + { + $sharedState = static fn (string $class, string $property, string $state, int $line): array => [ + sprintf(self::SHARED_STATE, self::NAMESPACE . $class, $property, $state), + $line, + self::SHARED_STATE_TIP, + ]; + $sharedWrite = static fn (string $class, string $method, string $property, int $line): array => [ + sprintf(self::SHARED_WRITE, self::NAMESPACE . $class, $method, $property), + $line, + self::SHARED_WRITE_TIP, + ]; + $staticWrite = static fn (string $class, string $property, string $context, int $line): array => [ + sprintf(self::STATIC_WRITE, self::NAMESPACE . $class, $property, $context), + $line, + self::STATIC_TIP, + ]; + $cache = static fn (string $kind, string $class, string $property, int $line): array => [ + sprintf(self::CACHE, $kind, self::NAMESPACE . $class, $property), + $line, + self::CACHE_TIP, + ]; + + return [ + 'shared' => [ + $sharedState('SharedService', 'request', 'the current request', 84), + $sharedState('NestedService', 'user', 'a user', 127), + $sharedState('SelfBuildingSingleton', 'request', 'the current request', 170), + $sharedState('CurrentUserSingleton', 'user', 'a user', 197), + $sharedState('CachedService', 'request', 'the current request', 221), + $sharedState('SingletonBoundService', 'request', 'the current request', 265), + $sharedState('ContractImplementation', 'request', 'the current request', 289), + $sharedState('AttributeSingleton', 'request', 'the current request', 311), + $sharedState('ReassignedUserService', 'user', 'a user', 355), + $sharedState('CurrentUserCacheService', 'user', 'a user', 383), + $sharedState('RequestCaptureService', 'request', 'the current request', 389), + $sharedState('ContextualImplementation', 'request', 'the current request', 404), + $sharedState('OtherConsumer', 'model', 'a user', 417), + $sharedWrite('CountingMiddleware', 'handle', 'handled', 434), + $sharedWrite('ReportController', 'show', 'shown', 452), + $sharedState('ReportService', 'request', 'the current request', 493), + $sharedState('RoutableService', 'request', 'the current request', 503), + $sharedState('ClosureService', 'request', 'the current request', 565), + $sharedState('JobService', 'request', 'the current request', 630), + $sharedState('SendReportListener', 'request', 'the current request', 647), + $sharedState('MappedHandler', 'request', 'the current request', 697), + $sharedWrite('HelperController', 'track', 'tracked', 736), + $sharedWrite('OverridingRecordingController', 'record', 'overridden', 798), + $sharedWrite('LexicalParentController', 'resolve', 'resolved', 816), + ], + 'static' => [ + [sprintf(self::STATIC_STATE, self::NAMESPACE . 'SharedService', 'current', 'the current request'), 85, self::STATIC_TIP], + $staticWrite('CountingMiddleware', 'seen', self::REQUEST, 435), + $staticWrite('StoreReportRequest', 'validated', self::REQUEST, 575), + $staticWrite('ProcessReport', 'runs', self::JOB, 590), + $staticWrite('RebuildReport', 'rebuilt', self::JOB, 843), + ], + 'cache' => [ + $cache('Property', 'SharedService', 'visits', 86), + $cache('Property', 'SharedService', 'visits', 87), + $cache('Property', 'SharedService', 'visits', 88), + $cache('Static property', 'SharedService', 'memo', 93), + $cache('Static property', 'CountingMiddleware', 'clients', 436), + ], + // Without the cache check, the request-keyed static write in middleware is still a request-time write. + 'static cache write' => [ + $staticWrite('CountingMiddleware', 'clients', self::REQUEST, 436), + ], + 'config' => [ + [sprintf(self::CONFIG, self::REQUEST), 437, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::REQUEST), 453, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::REQUEST), 454, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::REQUEST), 455, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::REQUEST), 456, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::REQUEST), 457, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 591, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 605, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 609, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 610, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::REQUEST), 737, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 844, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 855, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 857, self::CONFIG_TIP], + ], + ]; + } + + /** + * Create the helper with the test's switches. + */ + private function createHelper(): WorkerStateHelper + { + return new WorkerStateHelper( + $this->createReflectionProvider(), + self::getContainer()->getByType(CoroutineRuleHelper::class), + ...$this->switches, + ); + } + + /** + * Create the booted application the lifetimes are read from, apart from the shared analyzer application. + */ + private function createApplication(): Container + { + $container = new Container; + $container->instance('config', new ConfigRepository); + $container->alias('config', ConfigRepository::class); + $container->alias('config', Repository::class); + $container->alias('config', 'settings'); + $events = new Dispatcher($container); + $router = new Router($events, $container); + $bus = new BusDispatcher($container); + + $container->instance('events', $events); + $container->instance('router', $router); + $container->instance(BusDispatcherContract::class, $bus); + + $router->aliasMiddleware('counting', CountingMiddleware::class); + $router->get('/reports/{report}/{bound_routable}', [ReportController::class, 'show'])->middleware('counting'); + $router->get('/scoped', [ScopedController::class, 'index']); + $router->get('/binders/{thing}', [BinderController::class, 'show']); + $router->bind('thing', static fn (string $value): string => $value); + $router->get('/closure', static fn (ClosureService $service): string => ''); + $router->get('/helpers', [HelperController::class, 'index']); + $router->get('/scoped-helpers', [ScopedHelperController::class, 'index']); + $router->get('/records', [BaseRecordingController::class, 'index']); + $router->get('/lexical', [LexicalChildController::class, 'index']); + + $events->listen(ReportEvent::class, SendReportListener::class); + $bus->map([MappedCommand::class => MappedHandler::class]); + + $container->bind(BoundFreshService::class); + $container->singleton(SingletonBoundService::class); + $container->scoped(ScopedBoundService::class); + $container->singleton(FactoryBoundService::class, static fn (): FactoryBoundService => new FactoryBoundService); + $container->singleton(ServiceContract::class, ContractImplementation::class); + $container->singleton(BaseRecordingController::class, OverridingRecordingController::class); + $container->bind(Authenticatable::class, User::class); + $container->when(ConsumerService::class)->needs(ContextualDependency::class)->give(ContextualImplementation::class); + $container->when(OtherConsumer::class)->needs(User::class)->give(static fn (): User => new User); + $container->when(ContextualConfigJob::class)->needs(Repository::class)->give(LocalConfig::class); + + return $container; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/ExtendedConcurrencyManager.php b/tests/PHPStan/Rules/Coroutine/data/ExtendedConcurrencyManager.php new file mode 100644 index 0000000000..755d17623e --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/ExtendedConcurrencyManager.php @@ -0,0 +1,11 @@ + $callbacks + */ + public function keepsFirstFailure(array $callbacks): ?Throwable + { + $failure = null; + + foreach ($callbacks as $callback) { + try { + $callback(); + } catch (Throwable $exception) { + $failure ??= $exception; + } + } + + return $failure; + } + + public function keepsFailureSetInTry(): ?Throwable + { + $failure = null; + + try { + $failure = $this->attempt(); + $this->cleanUp(); + } catch (Throwable $exception) { + $failure ??= $exception; + } + + return $failure; + } + + public function keepsFailureInProperty(): void + { + $this->failure = null; + + try { + $this->cleanUp(); + } catch (Throwable $exception) { + $this->failure ??= $exception; + } + } + + /** + * @param list $callbacks + */ + public function guardsWithoutLeaving(array $callbacks): ?Throwable + { + $failure = null; + + foreach ($callbacks as $callback) { + try { + $callback(); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + $this->cleanUp(); + } + + $failure ??= $exception; + } + } + + return $failure; + } + + public function keepsOnlyFailure(): ?Throwable + { + $failure = null; + + try { + $this->cleanUp(); + } catch (Throwable $exception) { + $failure ??= $exception; + } + + return $failure; + } + + /** + * @param list $callbacks + */ + public function leavesOnCancellation(array $callbacks): ?Throwable + { + $failure = null; + + foreach ($callbacks as $callback) { + try { + $callback(); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + $failure ??= $exception; + } + } + + return $failure; + } + + /** + * @param list $callbacks + */ + public function keepsOrdinaryFailuresApart(array $callbacks): ?Throwable + { + $failure = null; + $cancellation = null; + + foreach ($callbacks as $callback) { + try { + $callback(); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + $cancellation = $exception; + } else { + $failure ??= $exception; + } + } + } + + return $cancellation ?? $failure; + } + + public function keepsCleanupFailure(Closure $work, Closure $cleanUp, ?Throwable $failure): ?Throwable + { + try { + $work(); + } catch (Throwable $exception) { + try { + $cleanUp(); + } catch (Throwable $exception) { + $failure ??= $exception; + } + } + + return $failure; + } + + public function keepsOrdinaryCleanupFailure(Closure $work, Closure $cleanUp, ?Throwable $failure): ?Throwable + { + try { + $work(); + } catch (Throwable $exception) { + try { + $cleanUp(); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + $failure ??= $exception; + } + } + + return $failure; + } + + private function attempt(): ?Throwable + { + return null; + } + + private function cleanUp(): void + { + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/concurrency-run.php b/tests/PHPStan/Rules/Coroutine/data/concurrency-run.php new file mode 100644 index 0000000000..fe49a0f142 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/concurrency-run.php @@ -0,0 +1,18 @@ + 1]); + }); + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/config/concurrency.php b/tests/PHPStan/Rules/Coroutine/data/config/concurrency.php new file mode 100644 index 0000000000..42f26759cf --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/config/concurrency.php @@ -0,0 +1,13 @@ + 'background', + + 'driver' => [ + 'background' => [ + 'driver' => 'coroutine', + ], + ], +]; diff --git a/tests/PHPStan/Rules/Coroutine/data/configured-concurrency.neon b/tests/PHPStan/Rules/Coroutine/data/configured-concurrency.neon new file mode 100644 index 0000000000..9352eb9b34 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/configured-concurrency.neon @@ -0,0 +1,5 @@ +includes: + - ../../phpstan-rules.neon +parameters: + configDirectories: + - %rootDir%/../../../tests/PHPStan/Rules/Coroutine/data/config diff --git a/tests/PHPStan/Rules/Coroutine/data/coroutines-in-transactions.php b/tests/PHPStan/Rules/Coroutine/data/coroutines-in-transactions.php new file mode 100644 index 0000000000..625c2e97d1 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/coroutines-in-transactions.php @@ -0,0 +1,118 @@ + null); + go(fn () => null); + $concurrent->fork(fn () => null); + $waitConcurrent->fork(fn () => null); + Concurrency::run([fn () => 1]); + $manager->run([fn () => 1]); + parallel([]); + Concurrency::run(tasks: []); + $start = Coroutine::create(...); + }); + } + + public function spawnsWhileDeferring(): void + { + DB::transaction(function (): void { + DB::afterCommit(function (): void { + Coroutine::create(fn () => null); + }); + DB::afterCommit($this->callbackFor(Coroutine::create(fn () => null))); + }); + } + + public function spawnsInChild(): void + { + DB::transaction(function (): void { + Coroutine::create(function (): void { + Coroutine::create(fn () => null); + }); + Coroutine::create( + $this->callbackFor(Coroutine::create(fn () => null)), + ); + }); + } + + public function spawnsOutsideTransaction(): void + { + Coroutine::create(fn () => null); + } + + public function spawnsAfterInvalidation(TrackedOrder $order): void + { + DB::transaction(function () use ($order): void { + $order->status = 'paid'; + $order->save(); + Coroutine::create(fn () => null); + }); + } + + public function spawnsInTasks(ConcurrencyManager $manager): void + { + DB::transaction(function () use ($manager): void { + $manager->run([function (): void { + Coroutine::create(fn () => null); + array_map(fn (int $value): int => Coroutine::create(fn () => null), [1]); + }]); + Concurrency::driver('sync')->run([function (): void { + Coroutine::create(fn () => null); + }]); + }); + } + + public function runsSelectedDrivers(ConcurrencyManager $manager, string $name): void + { + DB::transaction(function () use ($manager, $name): void { + Concurrency::driver('coroutine')->run([function (): void { + Coroutine::create(fn () => null); + }]); + $manager->driver('coroutine')->run([fn () => 1]); + Concurrency::driver($name)->run([function (): void { + Coroutine::create(fn () => null); + }]); + Concurrency::driver('process')->run([function (): void { + Coroutine::create(fn () => null); + }]); + Concurrency::driver('process')->run([$this->callbackFor(Coroutine::create(fn () => null))]); + }); + } + + /** + * @return Closure(): void + */ + private function callbackFor(int $coroutineId): Closure + { + return function (): void { + }; + } +} + +class TrackedOrder +{ + public string $status = 'new'; + + public function save(): void + { + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/exact-cancellation-catches.php b/tests/PHPStan/Rules/Coroutine/data/exact-cancellation-catches.php new file mode 100644 index 0000000000..20f3edb0c2 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/exact-cancellation-catches.php @@ -0,0 +1,270 @@ +work(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + $this->report($exception); + + return 0; + } + + return 1; + } + + public function guardsWithoutLeaving(): int + { + try { + $this->work(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + $this->report($exception); + } + + return 0; + } + + return 1; + } + + public function guardsAnotherException(Throwable $previous): int + { + try { + $this->work(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($previous)) { + throw $exception; + } + + return 0; + } + + return 1; + } + + public function waitsAndWorks(Channel $channel): mixed + { + try { + $value = $channel->pop(1.0); + $this->work(); + + return $value; + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } + + public function waitsOnOverriddenChannel(PollingChannel $channel): mixed + { + try { + return $channel->pop(1.0); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } + + public function rethrowsCarriedCancellation(): void + { + try { + $this->work(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + $this->report($exception); + } + } + + public function onlyRethrows(): void + { + try { + $this->work(); + } catch (CanceledException $exception) { + $this->cleanUp(); + + throw $exception; + } catch (Throwable $exception) { + throw $exception; + } + } + + public function separatesBranches(): void + { + try { + $this->work(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + $this->cleanUp(); + } else { + $this->report($exception); + } + } + } + + public function handlesOnlyOrdinaryFailures(): void + { + try { + $this->work(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + if (! Coroutine::causedByCancellation($exception)) { + $this->report($exception); + } + } + } + + /** + * @param list $coroutineIds + */ + public function waitsNatively(Channel $channel, WaitGroup $waitGroup, array $coroutineIds): mixed + { + try { + $waitGroup->wait(1.0); + Coroutine::join($coroutineIds, 1.0); + $results = new Channel(1); + $later = $this->work(...); + + return $channel->pop(1.0); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } + + public function waitsOnConstructedChannel(): mixed + { + try { + $channel = new Channel(1); + + return $channel->pop(1.0); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } + + public function waitsOnReplacedChannel(Channel $channel): mixed + { + try { + $channel = $this->replacement(); + + return $channel->pop(1.0); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } + + public function waitsOnConditionallyConstructedChannel(PollingChannel $channel, bool $replace): mixed + { + try { + if ($replace) { + $channel = new Channel(1); + } + + return $channel->pop(1.0); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } + + public function keepsCancellationForLater(): ?Throwable + { + $cancellation = null; + + try { + $this->work(); + } catch (CanceledException $exception) { + $cancellation = $exception; + } catch (Throwable $exception) { + $this->report($exception); + } + + return $cancellation; + } + + public function endsOnCancellation(): void + { + try { + $this->work(); + } catch (CanceledException) { + return; + } catch (Throwable $exception) { + $this->report($exception); + } + } + + public function hasNoBroadCatch(): void + { + try { + $this->work(); + } catch (CanceledException $exception) { + throw $exception; + } catch (RuntimeException $exception) { + $this->report($exception); + } + } + + private function work(): void + { + } + + private function cleanUp(): void + { + } + + private function replacement(): Channel + { + return new PollingChannel(1); + } + + private function report(Throwable $exception): void + { + } +} + +/** + * @extends Channel + */ +class PollingChannel extends Channel +{ + public function pop(float $timeout = -1): mixed + { + return parent::pop($timeout); + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/exact-cancellation-checks.php b/tests/PHPStan/Rules/Coroutine/data/exact-cancellation-checks.php new file mode 100644 index 0000000000..239655cebb --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/exact-cancellation-checks.php @@ -0,0 +1,36 @@ +getPrevious() instanceof CanceledException; + } + + public function checksRejectionReason(mixed $reason): bool + { + return $reason instanceof CanceledException; + } + + public function checksNonException(int|string $value): bool + { + return $value instanceof CanceledException; + } + + public function checksKnownCancellation(CanceledException $exception): bool + { + return $exception instanceof CanceledException; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/raw-sleep.php b/tests/PHPStan/Rules/Coroutine/data/raw-sleep.php new file mode 100644 index 0000000000..b1538abadc --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/raw-sleep.php @@ -0,0 +1,27 @@ +set('app.name', 'Hypervel'); +}); diff --git a/tests/PHPStan/Rules/Coroutine/data/worker-state-routes.php b/tests/PHPStan/Rules/Coroutine/data/worker-state-routes.php new file mode 100644 index 0000000000..b5a622ff4a --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/worker-state-routes.php @@ -0,0 +1,60 @@ + 'Hypervel']); + + collect([1])->each(function (): void { + CountingMiddleware::$seen = true; + }); + collect([1])->each(fn (): bool => CountingMiddleware::$seen = true); +}); + +Route::post('/b', action: fn (): bool => CountingMiddleware::$seen = true); + +\Route::put('/c', function (): void { + CountingMiddleware::$seen = true; +}); + +Route::middleware('web')->get('/d', function (): void { + CountingMiddleware::$seen = true; +}); + +Route::middleware('web')->group(function (): void { + CountingMiddleware::$seen = true; +}); + +Route::get('/f', function (): string { + $later = function (): void { + CountingMiddleware::$seen = true; + }; + $after = fn (): bool => CountingMiddleware::$seen = true; + tap(null, CountingMiddleware::$seen = true); + + return ''; +}); + +Route::get('/g', function (ConfigRepository $config, ?Repository $optional = null): void { + $config['app.name'] = 'Hypervel'; + $optional->set('app.name', 'Hypervel'); +}); + +function registerRoutes(Router $router): void +{ + $router->patch('/e', function (): void { + CountingMiddleware::$seen = true; + }); +} + +collect([1])->each(function (): void { + CountingMiddleware::$seen = true; +}); diff --git a/tests/PHPStan/Rules/Coroutine/data/worker-state.php b/tests/PHPStan/Rules/Coroutine/data/worker-state.php new file mode 100644 index 0000000000..b4b56755d1 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/worker-state.php @@ -0,0 +1,860 @@ +make(TransientService::class); + App::make(SelfBuildingService::class); + $container->get(ExecutionScopedService::class); + $container->factory(BoundFreshService::class); + app(FactoryBoundService::class); + app(ServiceContract::class); + app(BindAttributedService::class); + app(RepositoryService::class); + app(AssignedRepositoryService::class); + app(AuthenticatableService::class); + app(CurrentUserCacheService::class); + app(RequestCaptureService::class); + app(ConsumerService::class); + app(OtherConsumer::class); + app(CachedService::class); + app(EarlyCaptureService::class); + app(MemoCachedService::class); + app(ParameterOnlyService::class, ['value' => 1]); +} + +class SharedService +{ + public static array $memo = []; + + private static ?Request $current = null; + + private ?Request $request = null; + + /** @var array */ + private array $visits = []; + + /** @var array */ + private array $pages = []; + + /** @var array */ + private array $recent = []; + + private WeakMap $users; + + private int $count = 0; + + public function __construct(private NestedService $nested) + { + $this->users = new WeakMap; + } + + public function remember(Request $request): void + { + $this->request = $request; + static::$current = $request; + $this->visits[$request->ip()] = 1; + $this->visits[request()->path()] = 1; + $this->visits[Auth::id()] = 1; + $this->visits[$request->isMethod('post') ? 'post' : 'other'] = 1; + $this->pages[$request->integer('page') % 1000] = 1; + $this->users[$request->user()] = 1; + $this->count++; + static::$memo[$request->ip()] ??= 1; + } + + public function rememberLatest(Request $request): void + { + $this->recent = []; + $this->recent[$request->ip()] = true; + } + + /** + * Set the request used for testing. + * + * Tests only. The request persists on the shared service. + */ + public function setRequestForTesting(Request $request): void + { + $this->request = $request; + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$memo = []; + } +} + +class NestedService +{ + private ?User $user = null; + + public function remember(User $user): void + { + $this->user = $user; + } +} + +#[Scoped] +class ScopedService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class TransientService implements Transient +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class SelfBuildingService implements SelfBuilding +{ + private ?Request $request = null; + + public static function newInstance(): static + { + return new static; + } + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +#[Singleton] +class SelfBuildingSingleton implements SelfBuilding +{ + public function __construct(public ?Request $request = null) + { + } + + public static function newInstance(): static + { + return new static; + } +} + +class ExecutionScopedService +{ + private ?Request $request = null; + + public function __construct(#[CurrentUser] public User $user) + { + } + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +#[Singleton] +class CurrentUserSingleton +{ + public function __construct(#[CurrentUser] public User $user) + { + } +} + +#[Singleton] +class TestingRequestHolder +{ + /** Tests only. */ + public function __construct(public Request $request) + { + } +} + +class CachedService +{ + private ?Request $request = null; + + public function __construct(#[Cache] public CacheRepository $cache) + { + } + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class MemoCachedService +{ + private ?Request $request = null; + + public function __construct(#[Cache(memo: true)] public CacheRepository $cache) + { + } + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class BoundFreshService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class ScopedBoundService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class SingletonBoundService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class FactoryBoundService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +interface ServiceContract +{ +} + +class ContractImplementation implements ServiceContract +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +#[Bind(ContractImplementation::class)] +class BindAttributedService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +#[Singleton] +class AttributeSingleton +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class ParameterOnlyService +{ + private ?Request $request = null; + + public function __construct(public int $value) + { + } + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +// Constructor captures: the container passes a fresh model, but not the current user. +class RepositoryService +{ + public function __construct(protected User $model) + { + } +} + +class AssignedRepositoryService +{ + protected User $model; + + public function __construct(User $model) + { + $this->model = $model; + } +} + +#[Singleton] +class ReassignedUserService +{ + private User $user; + + public function __construct(User $user) + { + $user = User::query()->firstOrFail(); + $this->user = $user; + } +} + +class EarlyCaptureService +{ + private User $model; + + public function __construct(User $model) + { + $this->model = $model; + $model = User::query()->firstOrFail(); + } +} + +class AuthenticatableService +{ + public function __construct(public Authenticatable $user) + { + } +} + +class CurrentUserCacheService +{ + private ?Authenticatable $user; + + public function __construct() + { + $this->user = auth()->user(); + } +} + +class RequestCaptureService +{ + public function __construct(private Request $request) + { + } +} + +interface ContextualDependency +{ +} + +class ContextualImplementation implements ContextualDependency +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class ConsumerService +{ + public function __construct(public ContextualDependency $dependency, public User $model) + { + } +} + +class OtherConsumer +{ + public function __construct(public User $model) + { + } +} + +// HTTP roots and contexts. +class CountingMiddleware +{ + public static bool $seen = false; + + /** @var array */ + public static array $clients = []; + + private int $handled = 0; + + public function handle(Request $request, Closure $next): mixed + { + $this->handled++; + static::$seen = true; + static::$clients[$request->ip()] = true; + config(['app.locale' => 'fr']); + collect([1])->each(function (): void { + static::$seen = true; + }); + + return $next($request); + } +} + +class ReportController +{ + private int $shown = 0; + + public function show(ReportService $report, RoutableService $routable, BoundRoutable $boundRoutable, ConfigRepository $config, ?DefaultedService $defaulted = null): string + { + $this->shown++; + $config->set('app.timezone', 'UTC'); + $config['app.name'] = 'Hypervel'; + $config['app.name'] .= ' Reports'; + unset($config['app.url']); + config()['app.locale'] = 'fr'; + $local = new ConfigRepository; + $local->set('app.timezone', 'UTC'); + + return ''; + } +} + +abstract class BaseController +{ + protected int $hits = 0; + + public function index(): string + { + $this->hits++; + + return ''; + } +} + +#[Scoped] +class ScopedController extends BaseController +{ +} + +#[Singleton] +class SharedNonController extends BaseController +{ +} + +class ReportService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class RoutableService implements UrlRoutable +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } + + public function getRouteKey(): mixed + { + return 1; + } + + public function getRouteKeyName(): string + { + return 'id'; + } + + public function resolveRouteBinding(mixed $value, ?string $field = null): ?Model + { + return null; + } + + public function resolveChildRouteBinding(string $childType, mixed $value, ?string $field): ?Model + { + return null; + } +} + +class BoundRoutable extends RoutableService +{ +} + +class DefaultedService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class BinderController +{ + public function show(BinderService $service): string + { + return ''; + } +} + +class BinderService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class ClosureService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class StoreReportRequest extends FormRequest +{ + public static int $validated = 0; + + public function rules(): array + { + static::$validated++; + + return []; + } +} + +// Jobs, listeners and commands. +class ProcessReport implements ShouldQueue +{ + use Dispatchable; + + public static int $runs = 0; + + public function handle(JobService $service, Repository $config): void + { + static::$runs++; + $config->set('app.debug', true); + } +} + +class LocalConfig extends ConfigRepository implements Transient +{ +} + +class ConfigureReport implements ShouldQueue +{ + use Dispatchable; + + public function handle(Repository $config): void + { + $config->set('app.timezone', 'UTC'); + $config = new LocalConfig; + $config->set('app.debug', true); + app(LocalConfig::class)->set('app.debug', true); + app('config')->set('app.debug', true); + app('settings')->set('app.debug', true); + } +} + +class ContextualConfigJob implements ShouldQueue +{ + use Dispatchable; + + public function handle(Repository $config): void + { + $config->set('app.debug', true); + } +} + +class JobService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class ReportEvent +{ + public function __construct(public Request $request) + { + } +} + +class SendReportListener implements ShouldQueue +{ + private ?Request $request = null; + + public function handle(ReportEvent $event): void + { + $this->request = $event->request; + } +} + +class UnregisteredEvent +{ + public function __construct(public Request $request) + { + } +} + +class UnregisteredListener implements ShouldQueue +{ + public function handle(UnregisteredEvent $event): void + { + } +} + +class MappedCommand implements ShouldQueue +{ + use Dispatchable; + + public function handle(MappedService $service): void + { + } +} + +class MappedService +{ + private ?Request $request = null; + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class MappedHandler +{ + private ?MappedCommand $command = null; + + private ?Request $request = null; + + public function handle(MappedCommand $command): void + { + $this->command = $command; + } + + public function remember(Request $request): void + { + $this->request = $request; + } +} + +// Helpers a request or job method calls on its own instance handle the same operation. +class HelperController +{ + private int $tracked = 0; + + private bool $primed = false; + + private bool $configured = false; + + private int $deferred = 0; + + public function __construct() + { + $this->prime(); + } + + public function index(): string + { + $this->track(); + $later = $this->defer(...); + collect([1])->each(fn (): mixed => $this->defer()); + + return ''; + } + + /** + * Boot-only: the provider configures the controller once. + */ + public function configure(): void + { + $this->apply(); + } + + private function track(): void + { + $this->tracked++; + config(['app.tracked' => true]); + } + + private function prime(): void + { + $this->primed = true; + } + + private function apply(): void + { + $this->configured = true; + } + + private function defer(): void + { + $this->deferred++; + } +} + +#[Scoped] +class ScopedHelperController +{ + private int $tracked = 0; + + public function index(): string + { + $this->track(); + + return ''; + } + + private function track(): void + { + $this->tracked++; + } +} + +// The container binds the routed class to a subclass, whose override the inherited action calls. +abstract class BaseRecordingController +{ + protected int $recorded = 0; + + public function index(): string + { + $this->record(); + + return ''; + } + + protected function record(): void + { + $this->recorded++; + } +} + +class OverridingRecordingController extends BaseRecordingController +{ + protected int $overridden = 0; + + protected function record(): void + { + $this->overridden++; + } +} + +// A private method runs in the class whose code calls it, whatever the receiver declares. +class LexicalParentController +{ + protected int $resolved = 0; + + public function index(): string + { + $this->resolve(); + + return ''; + } + + private function resolve(): void + { + $this->resolved++; + } +} + +class LexicalChildController extends LexicalParentController +{ + protected int $childResolved = 0; + + public function resolve(): void + { + $this->childResolved++; + } +} + +class RebuildReport implements ShouldQueue +{ + use Dispatchable; + + public static int $rebuilt = 0; + + public function handle(): void + { + $this->count(); + } + + private function count(): void + { + static::$rebuilt++; + config(['app.rebuilt' => true]); + } +} + +// config() reads its key by name, so named arguments may come in any order. +class NamedConfigJob implements ShouldQueue +{ + use Dispatchable; + + public function handle(): void + { + config(default: null, key: ['app.named' => true]); + config(default: [], key: 'app.name'); + app(parameters: [], abstract: 'config')->set('app.named', true); + app('config', ['option' => true])->set('app.named', true); + } +} diff --git a/tests/PHPStan/Rules/ModelAppendsRuleTest.php b/tests/PHPStan/Rules/ModelAppendsRuleTest.php new file mode 100644 index 0000000000..3aec201561 --- /dev/null +++ b/tests/PHPStan/Rules/ModelAppendsRuleTest.php @@ -0,0 +1,50 @@ + */ +class ModelAppendsRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(ModelAppendsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/ModelAppends.php'], [ + ["Property 'non_existent' does not exist in model.", 15], + ["Property 'email' is not a computed property, remove from \$appends.", 16], + // Hypervel: an appended column with a get accessor serializes through it, so line 17 is valid. + ]); + } + + public function testHypervelDeclarationsAndComputedProperties(): void + { + $this->analyse([__DIR__ . '/data/model-appends-hypervel.php'], [ + ["Property 'missing' does not exist in model.", 15], + ["Property 'email' is not a computed property, remove from #[Appends].", 16], + ["Property 'password' is not a computed property, remove from \$appends.", 23], + ["Property 'backed_enum' is not a computed property, remove from \$appends.", 24], + ["Property 'name' is not a computed property, remove from \$appends.", 51], + ["Property 'full_name' does not exist in model.", 90], + ["Property 'trait_name' does not exist in model.", 99], + ["Property 'shared_missing' does not exist in model.", 122], + ["Property 'email_verified_at' is not a computed property, remove from \$appends.", 129], + ["Property 'missing_spread' does not exist in model.", 153], + ["Property 'missing_item' does not exist in model.", 154], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/ModelPropertyColumnRuleTest.php b/tests/PHPStan/Rules/ModelPropertyColumnRuleTest.php new file mode 100644 index 0000000000..e961c583cb --- /dev/null +++ b/tests/PHPStan/Rules/ModelPropertyColumnRuleTest.php @@ -0,0 +1,130 @@ + */ +class ModelPropertyColumnRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(ModelPropertyColumnRule::class); + } + + public function testRule(): void + { + $builderColumns = "'*'|array|model property of App\\User"; + $userTip = 'Type #2 from the union: The given string should be a property of App\User, nonexistent given.'; + $userArrayTip = 'Type #2 from the union: Type #3 from the union: The given string should be a property of App\User, nonexistent given.'; + $userColumnTip = 'Column nonexistent is not a property of App\User.'; + + $this->analyse([__DIR__ . '/data/model-property-columns.php'], [ + [ + 'Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.', + 45, + $userTip, + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Builder::paginate() expects {$builderColumns}, array{'id', 'nonexistent'} given.", + 48, + $userArrayTip, + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Builder::simplePaginate() expects {$builderColumns}, array{'nonexistent'} given.", + 50, + $userArrayTip, + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Builder::cursorPaginate() expects {$builderColumns}, 'nonexistent' given.", + 51, + 'Type #3 from the union: The given string should be a property of App\User, nonexistent given.', + ], + [ + 'Parameter #1 $column of static method App\User::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.', + 53, + $userTip, + ], + [ + "Parameter #2 \$columns of static method App\\User::paginate() expects {$builderColumns}, array{'nonexistent'} given.", + 54, + $userArrayTip, + ], + [ + 'Parameter #1 $column of method Hypervel\Database\Eloquent\Relations\HasMany::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\Account, string given.', + 56, + 'Type #2 from the union: The given string should be a property of App\Account, nonexistent given.', + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Relations\\BelongsToMany::paginate() expects array, array{'nonexistent'} given.", + 57, + 'Type #3 from the union: The given string should be a property of App\Role, nonexistent given.', + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Relations\\HasManyThrough::simplePaginate() expects array, array{'nonexistent'} given.", + 58, + 'Type #3 from the union: The given string should be a property of App\Transaction, nonexistent given.', + ], + [ + 'Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\SearchableUser, string given.', + 64, + 'Type #2 from the union: The given string should be a property of App\SearchableUser, nonexistent given.', + ], + [ + 'Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User|ModelPropertyColumns\AbstractSubject, string given.', + 67, + 'Type #2 from the union: The given string should be a property of App\User|ModelPropertyColumns\AbstractSubject, nonexistent given.', + ], + [ + 'Parameter #1 $column of static method App\User::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.', + 70, + $userTip, + ], + [ + 'Call to an undefined method Hypervel\Database\Eloquent\Builder::whereNameOrNonexistent().', + 105, + $userColumnTip, + ], + [ + 'Call to an undefined static method App\User::whereNonexistent().', + 106, + $userColumnTip, + ], + [ + 'Call to an undefined method App\User::whereNonexistent().', + 107, + $userColumnTip, + ], + [ + 'Call to an undefined method Hypervel\Database\Eloquent\Relations\HasMany::whereNonexistent().', + 108, + 'Column nonexistent is not a property of App\Account.', + ], + [ + 'Call to an undefined method ModelPropertyColumns\WhereBuilder::whereNonexistent().', + 109, + $userColumnTip, + ], + [ + 'Parameter #1 $column of method Hypervel\Database\Eloquent\Builder::pluck() expects Hypervel\Contracts\Database\Query\Expression|model property of App\User, string given.', + 110, + $userTip, + ], + [ + "Parameter #2 \$columns of method Hypervel\\Database\\Eloquent\\Builder::paginate() expects {$builderColumns}, array{'users.nonexistent'} given.", + 123, + 'Type #2 from the union: Type #3 from the union: Database table "users" does not have column "nonexistent"', + ], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/ModelRelationDefaultsRuleTest.php b/tests/PHPStan/Rules/ModelRelationDefaultsRuleTest.php new file mode 100644 index 0000000000..79f6b09765 --- /dev/null +++ b/tests/PHPStan/Rules/ModelRelationDefaultsRuleTest.php @@ -0,0 +1,40 @@ + */ +class ModelRelationDefaultsRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(ModelRelationDefaultsRule::class); + } + + public function testDefaults(): void + { + $this->analyse([__DIR__ . '/data/model-relation-defaults.php'], [ + ["Relation 'missing' is not found in ModelRelationDefaults\\InvalidDefaults model.", 19], + ["Relation 'missing' is not found in App\\Account model.", 19], + ["Relation 'missing' is not found in ModelRelationDefaults\\InvalidDefaults model.", 20], + ["Relation 'accounts as total' is not found in ModelRelationDefaults\\InvalidDefaults model.", 20], + ["Relation 'accounts.transactions' is not found in ModelRelationDefaults\\InvalidDefaults model.", 20], + ["Relation 'children' is not found in ModelRelationDefaults\\InvalidChild model.", 38], + ["Relation 'children' is not found in ModelRelationDefaults\\InvalidChild model.", 38], + ["Relation 'missing' is not found in App\\Account model.", 51], + ["Relation 'missing' is not found in ModelRelationDefaults\\ConstantDefaults model.", 57], + ["Relation 'missing' is not found in ModelRelationDefaults\\SharedDeclaration model.", 73], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/NoAuthFacadeInRequestScopeRuleTest.php b/tests/PHPStan/Rules/NoAuthFacadeInRequestScopeRuleTest.php new file mode 100644 index 0000000000..39995140ff --- /dev/null +++ b/tests/PHPStan/Rules/NoAuthFacadeInRequestScopeRuleTest.php @@ -0,0 +1,39 @@ + */ +class NoAuthFacadeInRequestScopeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new NoAuthFacadeInRequestScopeRule; + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/no-auth-facade-in-request-scope-rule.php'], [ + ['Do not use Auth::check() in a class that has access to the request. Use $request->user() !== null instead.', 17], + ['Do not use Auth::user() in a class that has access to the request. Use $request->user() instead.', 22], + ['Do not use Auth::guest() in a class that has access to the request. Use $request->user() === null instead.', 27], + ['Do not use Auth::check() in a class that has access to the request. Use $this->user() !== null instead.', 40], + ['Do not use Auth::check() in a class that has access to the request. Use $request->user() !== null instead.', 53], + ]); + } + + // REMOVED: testFix; the replacement depends on request user resolvers, so the rule offers no fix. + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-rules.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php b/tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php new file mode 100644 index 0000000000..598743d67b --- /dev/null +++ b/tests/PHPStan/Rules/NoAuthHelperInRequestScopeRuleTest.php @@ -0,0 +1,40 @@ + */ +class NoAuthHelperInRequestScopeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new NoAuthHelperInRequestScopeRule($this->createReflectionProvider()); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/no-auth-helper-in-request-scope-rule.php'], [ + ['Do not use auth()->check() in a class that has access to the request. Use $request->user() !== null instead.', 23], + ['Do not use auth()->user() in a class that has access to the request. Use $request->user() instead.', 28], + ['Do not use auth()->guest() in a class that has access to the request. Use $request->user() === null instead.', 33], + ['Do not use auth()->check() in a class that has access to the request. Use $this->user() !== null instead.', 51], + ['Do not use auth()->check() in a class that has access to the request. Use $request->user() !== null instead.', 64], + ['Do not use auth()->check() in a class that has access to the request. Use $request->user() !== null instead.', 72], + ]); + } + + // REMOVED: testFix; the replacement depends on request user resolvers, so the rule offers no fix. + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-rules.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php b/tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php new file mode 100644 index 0000000000..8f7d986023 --- /dev/null +++ b/tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php @@ -0,0 +1,178 @@ + */ +class NoEnvCallsOutsideOfConfigRuleTest extends RuleTestCase +{ + private const string MESSAGE = "Called 'env' outside of the config directory. The .env file is not loaded when the configuration is cached, so use 'config' instead."; + + private const string READER_MESSAGE = "Called '%s' outside of the config directory. The .env file is not loaded when the configuration is cached, so use 'config' instead."; + + private ?string $singleReflectionFile = null; + + private ?string $singleReflectionInsteadOfFile = null; + + // Upstream overrides the config path in setUp(); env-calls.neon sets configDirectories instead. + + protected function getRule(): Rule + { + return new NoEnvCallsOutsideOfConfigRule( + self::getContainer()->getByType(ConfigSourceResolver::class), + $this->getFileHelper(), + $this->createReflectionProvider(), + self::getContainer()->getByType(RuntimeInputs::class), + $this->singleReflectionFile, + $this->singleReflectionInsteadOfFile, + ); + } + + private function setSingleReflectionFiles(?string $file, ?string $insteadOfFile): void + { + $this->singleReflectionFile = $file; + $this->singleReflectionInsteadOfFile = $insteadOfFile; + } + + #[Test] + public function itDoesNotFailForEnvCallsInsideConfigDirectory(): void + { + $this->analyse([__DIR__ . '/data/config/env-calls.php'], []); + } + + #[Test] + public function itDoesNotFailForEnvCallsInsideGlobConfigDirectory(): void + { + $this->analyse([__DIR__ . '/data/module/foo/config/env-calls.php', __DIR__ . '/data/module/bar/config/env-calls.php'], []); + } + + #[Test] + public function itReportsEnvCallsOutsideOfConfigDirectory(): void + { + $this->analyse([__DIR__ . '/data/env-calls.php'], [ + [self::MESSAGE, 7], + [self::MESSAGE, 8], + ]); + } + + #[Test] + public function itReportsEveryEnvReaderOutsideOfConfigDirectory(): void + { + $this->analyse([__DIR__ . '/data/env-readers.php'], [ + [sprintf(self::READER_MESSAGE, 'env_array'), 9], + [sprintf(self::READER_MESSAGE, 'env_or_fail'), 10], + [sprintf(self::READER_MESSAGE, 'app_id'), 11], + [sprintf(self::READER_MESSAGE, 'Hypervel\Support\Env::get'), 12], + [sprintf(self::READER_MESSAGE, 'Hypervel\Support\Env::getArray'), 13], + [sprintf(self::READER_MESSAGE, 'Hypervel\Support\Env::getOrFail'), 14], + [sprintf(self::READER_MESSAGE, 'Hypervel\Support\Env::get'), 17], + [sprintf(self::READER_MESSAGE, 'Hypervel\Testbench\Foundation\Env::get'), 18], + ]); + } + + #[Test] + public function itDoesNotFailForEnvCallsInConfigPublishStubs(): void + { + $this->analyse([__DIR__ . '/../../../src/foundation/config-stubs/app.php'], []); + } + + #[Test] + public function itReportsEnvCallsInDirectoryNamedLikeConfigDirectory(): void + { + $this->analyse([__DIR__ . '/data/config-sibling/env-calls.php'], [ + [self::MESSAGE, 6], + ]); + } + + #[Test] + public function itDoesNotFailForEnvCallsInPackageConfigFiles(): void + { + $this->analyse([ + __DIR__ . '/../Fixtures/Config/config/package.php', + __DIR__ . '/../Fixtures/Config/config/published.php', + ], []); + } + + #[Test] + public function itDoesNotReportTraitFunctionsThatHaveBeenOverridden(): void + { + $this->analyse([ + __DIR__ . '/data/EnvUsageClassOverride.php', + __DIR__ . '/data/EnvUsageTrait.php', + ], []); + } + + #[Test] + public function itReportsEnvCallsInTraitRatherThanClass(): void + { + $actualErrors = $this->gatherAnalyserErrors([ + __DIR__ . '/data/EnvUsageClass.php', + __DIR__ . '/data/EnvUsageTrait.php', + ]); + + $this->assertCount(2, $actualErrors); + $this->assertSame(self::MESSAGE, $actualErrors[0]->getMessage()); + $this->assertSame( + __DIR__ . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'EnvUsageTrait.php (in context of class Hypervel\Tests\PHPStan\Rules\Data\EnvUsageClass)', + $actualErrors[0]->getFile(), + ); + $this->assertSame(17, $actualErrors[0]->getLine()); + + $this->assertSame(self::MESSAGE, $actualErrors[1]->getMessage()); + $this->assertSame( + __DIR__ . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'EnvUsageTrait.php (in context of class Hypervel\Tests\PHPStan\Rules\Data\EnvUsageClass)', + $actualErrors[1]->getFile(), + ); + $this->assertSame(18, $actualErrors[1]->getLine()); + } + + #[Test] + public function itDoesNotReportInEditorModeWhenInsteadOfPointsAtConfigFile(): void + { + $this->setSingleReflectionFiles(__DIR__ . '/data/env-calls.php', __DIR__ . '/data/config/env-calls.php'); + + $this->analyse([__DIR__ . '/data/env-calls.php'], []); + } + + #[Test] + public function itReportsInEditorModeWhenInsteadOfIsOutsideConfig(): void + { + $this->setSingleReflectionFiles(__DIR__ . '/data/env-calls.php', __DIR__ . '/data/some-non-config-file.php'); + + $this->analyse([__DIR__ . '/data/env-calls.php'], [ + [self::MESSAGE, 7], + [self::MESSAGE, 8], + ]); + } + + #[Test] + public function itUsesNormalLogicWhenAnalysedFileDiffersFromTmpFile(): void + { + $this->setSingleReflectionFiles('/some/other/buffer.php', __DIR__ . '/data/config/env-calls.php'); + + $this->analyse([__DIR__ . '/data/env-calls.php'], [ + [self::MESSAGE, 7], + [self::MESSAGE, 8], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/data/env-calls.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/NoImplicitQueryBuilderCallRuleTest.php b/tests/PHPStan/Rules/NoImplicitQueryBuilderCallRuleTest.php new file mode 100644 index 0000000000..abbbde014c --- /dev/null +++ b/tests/PHPStan/Rules/NoImplicitQueryBuilderCallRuleTest.php @@ -0,0 +1,108 @@ + */ +class NoImplicitQueryBuilderCallRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoImplicitQueryBuilderCallRule::class); + } + + /** @param list $errors */ + #[DataProvider('dataCalls')] + public function testCalls(string $file, array $errors): void + { + $file = __DIR__ . '/data/' . $file; + $fixedFile = str_replace('.php', '-fixed.php', $file); + + $this->analyse([$file], $errors); + $this->fix($file, $fixedFile); + $this->analyse([$fixedFile], []); + } + + /** @return iterable}> */ + public static function dataCalls(): iterable + { + yield 'builder calls and legacy scopes' => [ + 'no-implicit-query-builder-call.php', + [ + ["Call to static method NoImplicitQueryBuilderCall\\User::where() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->where() instead.", 34], + ["Call to static method NoImplicitQueryBuilderCall\\User::active() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->active() instead.", 35], + ["Call to method NoImplicitQueryBuilderCall\\User::recent() is forwarded to the query builder.\n 💡 Use newQuery()->recent() instead.", 36], + ["Call to static method NoImplicitQueryBuilderCall\\User::where() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->where() instead.", 88], + ["Call to static method NoImplicitQueryBuilderCall\\User::find() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->find() instead.", 89], + ["Call to static method NoImplicitQueryBuilderCall\\User::create() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->create() instead.", 90], + ["Call to static method NoImplicitQueryBuilderCall\\User::first() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->first() instead.", 91], + ["Call to static method NoImplicitQueryBuilderCall\\User::whereIn() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->whereIn() instead.", 92], + ["Call to static method NoImplicitQueryBuilderCall\\User::active() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->active() instead.", 93], + ["Call to static method NoImplicitQueryBuilderCall\\User::recent() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->recent() instead.", 94], + ["Call to static method NoImplicitQueryBuilderCall\\User::whereId() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->whereId() instead.", 95], + ["Call to static method NoImplicitQueryBuilderCall\\Post::published() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\Post::query()->published() instead.", 96], + ["Call to method NoImplicitQueryBuilderCall\\User::where() is forwarded to the query builder.\n 💡 Use newQuery()->where() instead.", 97], + ["Call to method NoImplicitQueryBuilderCall\\User::find() is forwarded to the query builder.\n 💡 Use newQuery()->find() instead.", 98], + ["Call to method NoImplicitQueryBuilderCall\\User::active() is forwarded to the query builder.\n 💡 Use newQuery()->active() instead.", 99], + ["Call to method NoImplicitQueryBuilderCall\\User::recent() is forwarded to the query builder.\n 💡 Use newQuery()->recent() instead.", 100], + ["Call to method NoImplicitQueryBuilderCall\\User::whereId() is forwarded to the query builder.\n 💡 Use newQuery()->whereId() instead.", 101], + ["Call to method NoImplicitQueryBuilderCall\\Post::published() is forwarded to the query builder.\n 💡 Use newQuery()->published() instead.", 102], + ["Call to static method NoImplicitQueryBuilderCall\\User::where() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->where() instead.", 130], + ["Call to static method NoImplicitQueryBuilderCall\\User::active() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->active() instead.", 131], + ["Call to static method NoImplicitQueryBuilderCall\\User::find() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->find() instead.", 133], + ["Call to method NoImplicitQueryBuilderCall\\User::where() is forwarded to the query builder.\n 💡 Use newQuery()->where() instead.", 148], + // Hypervel: a where{Column} call is forwarded as a dynamic where whatever the column, which ModelPropertyColumnRule checks. + ["Call to static method NoImplicitQueryBuilderCall\\User::whereNonexistentColumn() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->whereNonexistentColumn() instead.", 153], + ["Call to method NoImplicitQueryBuilderCall\\User::whereNonexistentColumn() is forwarded to the query builder.\n 💡 Use newQuery()->whereNonexistentColumn() instead.", 154], + ["Call to static method NoImplicitQueryBuilderCall\\User::where() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->where() instead.", 160], + ["Call to static method NoImplicitQueryBuilderCall\\User::where() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->where() instead.", 161], + ["Call to method NoImplicitQueryBuilderCall\\User::where() is forwarded to the query builder.\n 💡 Use newQuery()->where() instead.", 164], + ["Call to method NoImplicitQueryBuilderCall\\User::where() is forwarded to the query builder.\n 💡 Use newQuery()->where() instead.", 165], + ["Call to static method NoImplicitQueryBuilderCall\\User::where() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCall\\User::query()->where() instead.", 167], + ], + ]; + + yield 'attribute scopes' => [ + 'no-implicit-query-builder-call-scopes.php', + [ + ["Call to static method NoImplicitQueryBuilderCallScopes\\User::active() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCallScopes\\User::query()->active() instead.", 56], + ["Call to method NoImplicitQueryBuilderCallScopes\\User::active() is forwarded to the query builder.\n 💡 Use newQuery()->active() instead.", 57], + ["Call to static method NoImplicitQueryBuilderCallScopes\\ChildUser::active() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCallScopes\\ChildUser::query()->active() instead.", 58], + ["Call to method NoImplicitQueryBuilderCallScopes\\ChildUser::active() is forwarded to the query builder.\n 💡 Use newQuery()->active() instead.", 59], + ], + ]; + + yield 'builder attribute' => [ + 'no-implicit-query-builder-call-custom-builder.php', + [ + ["Call to static method NoImplicitQueryBuilderCallCustomBuilder\\User::named() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCallCustomBuilder\\User::query()->named() instead.", 25], + ["Call to method NoImplicitQueryBuilderCallCustomBuilder\\User::named() is forwarded to the query builder.\n 💡 Use newQuery()->named() instead.", 26], + ], + ]; + + // Hypervel: a model that forwards the call differently from Model would get another query, while a + // custom newQuery() runs in both forms. + yield 'customized forwarding' => [ + 'no-implicit-query-builder-call-forwarding.php', + [ + ["Call to method NoImplicitQueryBuilderCallForwarding\\QueriedUser::where() is forwarded to the query builder.\n 💡 Use newQuery()->where() instead.", 41], + ["Call to static method NoImplicitQueryBuilderCallForwarding\\ScopedUser::where() is forwarded to the query builder.\n 💡 Use NoImplicitQueryBuilderCallForwarding\\ScopedUser::query()->where() instead.", 42], + ["Call to method NoImplicitQueryBuilderCallForwarding\\ScopedUser::where() is forwarded to the query builder.\n 💡 Use newQuery()->where() instead.", 43], + ], + ]; + } + + /** @return list */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../Type/data/config-check-model-properties.neon']; + } +} diff --git a/tests/PHPStan/Rules/NoLegacyModelPropertiesRuleTest.php b/tests/PHPStan/Rules/NoLegacyModelPropertiesRuleTest.php new file mode 100644 index 0000000000..f61073447c --- /dev/null +++ b/tests/PHPStan/Rules/NoLegacyModelPropertiesRuleTest.php @@ -0,0 +1,52 @@ + */ +class NoLegacyModelPropertiesRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoLegacyModelPropertiesRule::class); + } + + public function testRule(): void + { + $subclasses = "Subclasses' own properties and attributes can take precedence differently: review them, and keep the property where attributes cannot express the hierarchy. See https://hypervel.org/docs/static-analysis#model-configuration"; + $tip = "\n 💡 " . $subclasses; + $mergeTip = static fn (string $attribute): string => "\n 💡 • {$subclasses}\n• A subclass's own #[{$attribute}] replaces this one entirely, so it must repeat the values it inherits."; + + $this->analyse([__DIR__ . '/data/legacy-model-properties.php'], [ + ['Model property LegacyModelProperties\Post::$table should be replaced by attribute #[Table(self::TABLE)].' . $mergeTip('Table'), 23], + ['Model property LegacyModelProperties\Post::$connection should be replaced by attribute #[Connection(\LegacyModelProperties\ConnectionName::Reporting)].' . $tip, 25], + ["Model property LegacyModelProperties\\Post::\$fillable should be replaced by attribute #[Fillable(['title', 'body'])]." . $mergeTip('Fillable'), 27], + ['Model property LegacyModelProperties\Post::$guarded should be replaced by attribute #[Unguarded].' . $tip, 29], + ['Model property LegacyModelProperties\Post::$timestamps should be replaced by attribute #[WithoutTimestamps].' . $tip, 31], + ["Model property LegacyModelProperties\\Post::\$dateFormat should be replaced by attribute #[DateFormat('U')]." . $tip, 33], + ["Model property LegacyModelProperties\\Post::\$touches should be replaced by attribute #[Touches(['author'])]." . $mergeTip('Touches'), 35], + ['Model property LegacyModelProperties\Post::$primaryKey should be removed: the model resolves the same value without it.' . $tip, 37], + ["Model property LegacyModelProperties\\Token::\$primaryKey should be replaced by attribute #[Table(key: 'uuid', keyType: 'string')]." . $mergeTip('Table'), 42], + ["Model property LegacyModelProperties\\Token::\$keyType should be replaced by attribute #[Table(key: 'uuid', keyType: 'string')]." . $mergeTip('Table'), 44], + ['Model property LegacyModelProperties\Token::$incrementing should be replaced by attribute #[WithoutIncrementing].' . $tip, 46], + ['Model property LegacyModelProperties\SequencedToken::$incrementing should be replaced by attribute #[Table(incrementing: true)].' . $mergeTip('Table'), 51], + ["Model property LegacyModelProperties\\Invoice::\$primaryKey should be replaced by attribute #[Table('invoices', key: 'number')]." . $mergeTip('Table'), 57], + ["Model property LegacyModelProperties\\Invoice::\$guarded should be replaced by attribute #[Guarded(['number'])]." . $tip, 59], + ['Model property LegacyModelProperties\LedgerEntry::$incrementing should be removed: the model resolves the same value without it.' . $tip, 70], + ["Model property LegacyModelProperties\\AdminAccount::\$hidden should be replaced by attribute #[Hidden(['token', 'password'])]." . $mergeTip('Hidden'), 80], + ["Model property LegacyModelProperties\\BaseUser::\$hidden should be replaced by attribute #[Hidden(['password'])]." . $mergeTip('Hidden'), 85], + ['Model property LegacyModelProperties\BaseUser::$timestamps should be replaced by attribute #[WithoutTimestamps].' . $tip, 87], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/NoLegacyModelScopesRuleTest.php b/tests/PHPStan/Rules/NoLegacyModelScopesRuleTest.php new file mode 100644 index 0000000000..c7b65b03b4 --- /dev/null +++ b/tests/PHPStan/Rules/NoLegacyModelScopesRuleTest.php @@ -0,0 +1,32 @@ + */ +class NoLegacyModelScopesRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoLegacyModelScopesRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/model-scope-methods.php'], [ + ["Local query scope method 'scopeOwnedBy' should be declared as protected #[Scope] method 'ownedBy'.", 22], + ["Local query scope method 'scopeActive' should be declared as protected #[Scope] method 'active'.", 32], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/NoMissingTranslationsRuleTest.php b/tests/PHPStan/Rules/NoMissingTranslationsRuleTest.php new file mode 100644 index 0000000000..44c3fbff6f --- /dev/null +++ b/tests/PHPStan/Rules/NoMissingTranslationsRuleTest.php @@ -0,0 +1,366 @@ + */ +class NoMissingTranslationsRuleTest extends RuleTestCase +{ + /** @var string[] */ + private array $translationDirectories = []; + + private ?Translator $translator = null; + + protected function getRule(): Rule + { + $viewParser = new ViewParser($this->getContainer()->getService('currentPhpVersionSimpleDirectParser')); + $runtimeInputs = self::getContainer()->getByType(RuntimeInputs::class); + $viewFileHelper = new ViewFileHelper([], $this->getFileHelper(), $runtimeInputs); + + $filesystem = new Filesystem; + $rule = new NoMissingTranslationsRule(new UsedTranslationViewCollector($viewParser, new BladeScanner, $viewFileHelper, $filesystem), $viewFileHelper, $filesystem, $runtimeInputs, $this->translationDirectories); + + // A test's own translator replaces the application's without changing the shared application. + if ($this->translator !== null) { + $container = new Container; + $container->instance('translator', $this->translator); + $rule->setContainer($container); + } + + return $rule; + } + + /** @return array> */ + protected function getCollectors(): array + { + return [ + new UsedTranslationFunctionCollector($this->createReflectionProvider()), + new UsedTranslationTranslatorCollector, + new UsedTranslationFacadeCollector, + ]; + } + + public function testRule(): void + { + // Only keys whose group exists are reported, so the fixture application has a + // lang file for each group these keys use. + /** @see ../application/resources/views/translations.blade.php */ + $errorsFromBladeFile = [ + ['Translation "lang.get" has not been found.', 26], + ['Translation "lang.choice" has not been found.', 27], + // Lines 28 and 29 call Lang::trans() and Lang::transChoice(), which the translator does not have. + + ['Translation "a.b" has not been found.', 31], + ['Translation "a.b.c" has not been found.', 32], + ['Translation "a_b.c-d" has not been found.', 33], + ['Translation "a.b" has not been found.', 34], + ['Translation "a.b!" has not been found.', 35], + ['Translation "a.translate me!" has not been found.', 36], + ['Translation "a.über~" has not been found.', 37], + ['Translation "app.i\\\'m" has not been found.', 38], + ['Translation "app.i\'m" has not been found.', 39], + ['Translation "app.\"ok\"" has not been found.', 40], + ['Translation "app."ok"" has not been found.', 41], + ['Translation "a.b" has not been found.', 42], + + ['Translation "directive.lang" has not been found.', 44], + ['Translation "directive.choice" has not been found.', 45], + + ['Translation "surrounded.by.text" has not been found.', 47], + ['Translation "surrounded.by.html.tags" has not been found.', 48], + + ['Translation "trans.uppercase" has not been found.', 50], + // Line 51 calls another object's trans() method. + // REMOVED: line 52's $t() is a JavaScript translation function, whose messages may not come from the lang files. + ['Translation "double.underscore.helper.function" has not been found.', 53], + + ['Translation "lang.in.html.attribute" has not been found.', 55], + ['Translation "lang.with.extra.paramater" has not been found.', 56], + // Blade removes line 57's comment before compiling. + ['Translation "lang.in.html.comment" has not been found.', 58], + ['Translation "and.a.simple.translation.afterwards" has not been found.', 59], + // Blade leaves line 60's test@lang() as text, like an email address. + + ['Translation "lang.with.new' . "\n" . 'line.char" has not been found.', 71], + ['Translation "lang.spanning' . "\n" . 'multiple.lines" has not been found.', 72], + ['Translation "lang.with.a.back\slash.single.quotes" has not been found.', 74], + ['Translation "lang.with.a.back\slash.double.quotes" has not been found.', 75], + ['Translation "lang.with.a.hex.char" has not been found.', 76], + ]; + + $this->analyse([__DIR__ . '/data/Translation.php'], [ + ...$errorsFromBladeFile, + [ + 'Translation "messages.test" has not been found.', + 18, + ], + [ + 'Translation "messages.test" has not been found.', + 19, + ], + [ + 'Translation "messages.test" has not been found.', + 20, + ], + [ + 'Translation "messages.test" has not been found.', + 25, + ], + [ + 'Translation "messages.test" has not been found.', + 26, + ], + [ + 'Translation "messages.test" has not been found.', + 31, + ], + [ + 'Translation "messages.test" has not been found.', + 32, + ], + [ + 'Translation "sub/lines.farewell" has not been found.', + 40, + ], + [ + 'Translation "messages.test" has not been found.', + 49, + ], + [ + 'Translation "messages.test" has not been found.', + 50, + ], + ]); + } + + public function testTracksTranslationAndViewDirectories(): void + { + $tracked = []; + $scope = $this->createStubForIntersectionOfInterfaces([Scope::class, DependencyTracker::class]); + $scope->method('trackValueDependency')->willReturnCallback(static function (string $extension, string $key) use (&$tracked): void { + $tracked[] = [$extension, $key]; + }); + + $this->getRule()->processNode(new CollectedDataNode([], false), $scope); + + $valueExtension = self::getContainer()->getByType(RuntimeInputsResultCacheValueExtension::class); + + $this->assertContains([RuntimeInputsResultCacheValueExtension::class, $valueExtension->createKey(linkedDirectories: [ + dirname(__DIR__, 3) . '/src/translation/lang' => '*', + lang_path() => '*', + ])], $tracked); + + $this->assertContains([RuntimeInputsResultCacheValueExtension::class, $valueExtension->createKey( + linkedDirectories: array_fill_keys(view()->getFinder()->getPaths(), '*.blade.php'), + )], $tracked); + } + + public function testLookupArguments(): void + { + $this->translationDirectories = [__DIR__ . '/../Fixtures/Rules/lang']; + + // A custom directive replaces the built-in one, and its handler decides what its arguments mean. + $container = Container::getInstance(); + $compiler = $container->make('blade.compiler'); + $customCompiler = clone $compiler; + $customCompiler->directive('push', static fn (string $expression): string => ''); + $container->instance('blade.compiler', $customCompiler); + + /** @see ../application/resources/views/translation-lookups.blade.php */ + $errorsFromBladeFile = [ + ['Translation "lookups.missing" has not been found for locale "fr".', 1], + ['Translation "lookups.present" has not been found for locale "fr".', 3], + ['Translation "lookups.missing" has not been found for locale "fr".', 4], + ['Translation "lookups.missing" has not been found.', 5], + ['Translation "lookups.missing" has not been found.', 10], + ['Translation "lookups.missing" has not been found.', 11], + ['Translation "lookups.missing" has not been found.', 12], + ['Translation "lookups.missing" has not been found.', 21], + ['Translation "lookups.missing" has not been found.', 26], + ['Translation "lookups.missing" has not been found.', 27], + ['Translation "lookups.missing" has not been found.', 28], + ['Translation "lookups.missing" has not been found.', 29], + ['Translation "lookups.missing" has not been found.', 30], + ['Translation "lookups.missing" has not been found.', 32], + ['Translation "lookups.missing" has not been found.', 36], + ['Translation "lookups.missing" has not been found.', 38], + ['Translation "lookups.missing" has not been found.', 42], + ['Translation "lookups.missing" has not been found.', 44], + ['Translation "lookups.missing" has not been found.', 46], + ['Translation "lookups.missing" has not been found.', 47], + ]; + + try { + // The configured directories replace the application's, so none of its groups are known. + // Their JSON file has a line named like the lookups group, which must not hide the group. + $this->analyse([__DIR__ . '/../Fixtures/Rules/TranslationLookups.php'], [ + ...$errorsFromBladeFile, + ['Translation "lookups.missing" has not been found.', 20], + ['Translation "lookups.missing" has not been found for locale "fr".', 24], + ['Translation "lookups.present" has not been found for locale "fr".', 26], + ['Translation "lookups.missing" has not been found for locale "en".', 30], + ['Translation "lookups.missing" has not been found for locale "fr".', 30], + ['Translation "lookups.missing" has not been found.', 31], + ['Translation "lookups.missing" has not been found for locale "fr".', 31], + ['Translation "lookups.missing" has not been found for locale "fr".', 33], + ['Translation "lookups.missing" has not been found for locale "fr".', 34], + ['Translation "lookups.missing" has not been found for locale "fr".', 35], + ['Translation "lookups.missing" has not been found for locale "fr".', 36], + ['Translation "lookups.missing" has not been found for locale "fr".', 37], + ['Translation "lookups.missing" has not been found for locale "fr".', 38], + ['Translation "lookups.apples" has not been found for locale "fr".', 42], + ]); + } finally { + $container->instance('blade.compiler', $compiler); + } + } + + public function testBootRegisteredTranslations(): void + { + $translator = new Translator(new FileLoader(new Filesystem, []), 'en'); + $translator->setFallback('en'); + $translator->addNamespace('package', __DIR__ . '/../Fixtures/Rules/lang'); + $translator->addLines(['added.line' => 'Added'], 'en'); + $translator->handleMissingKeysUsing(static fn (): never => throw new LogicException('The rule ran the missing-key callback.')); + + $this->translator = $translator; + + $this->analyse([__DIR__ . '/../Fixtures/Rules/BootRegisteredTranslations.php'], [ + ['Translation "package::lookups.missing" has not been found.', 15], + ['Translation "added.missing" has not been found.', 17], + ['Translation "added.missing" has not been found for locale "fr".', 18], + ]); + } + + public function testInvalidLocales(): void + { + $translator = new Translator(new FileLoader(new Filesystem, []), 'en'); + $translator->addNamespace('package', __DIR__ . '/../Fixtures/Rules/lang'); + + $this->translator = $translator; + + // The translator throws for the locale, which must not drop the rule's other errors. + $this->analyse([__DIR__ . '/../Fixtures/Rules/InvalidTranslationLocales.php'], [ + ['Translation locale "../fr" contains invalid characters.', 14], + ['Translation "package::lookups.missing" has not been found.', 15], + ]); + } + + public function testViewsThatParseOnlyOnceCompiled(): void + { + $root = ParallelTesting::tempDir('PHPStanNoMissingTranslationsRule'); + $finder = view()->getFinder(); + $paths = $finder->getPaths(); + + (new Filesystem)->deleteDirectory($root); + mkdir($root, 0777, true); + // The native if is only closed once Blade compiles @endif, so the view parses only once compiled. + file_put_contents($root . '/mixed.blade.php', "\n{{ __('package::lookups.missing') }}\n@endif\n"); + + $translator = new Translator(new FileLoader(new Filesystem, []), 'en'); + $translator->addNamespace('package', __DIR__ . '/../Fixtures/Rules/lang'); + + $this->translator = $translator; + $finder->setPaths([$root]); + + try { + // PHPStan runs the rule only when a collector collected something, so the analyzed file makes a lookup too. + $this->analyse([__DIR__ . '/data/NamespacedTranslationFunction.php'], [ + ['Translation "package::lookups.missing" has not been found.', 2], + ['Translation "package::lookups.missing" has not been found.', 21], + ]); + } finally { + $finder->setPaths($paths); + $finder->flush(); + (new Filesystem)->deleteDirectory($root); + } + } + + public function testLocalesFromTheTranslatorsResolver(): void + { + $translator = new Translator(new FileLoader(new Filesystem, []), 'en'); + $translator->setFallback('en'); + $translator->addNamespace('package', __DIR__ . '/../Fixtures/Rules/lang'); + $translator->determineLocalesUsing(static fn (array $locales): array => [...$locales, 'es']); + + $this->translator = $translator; + + // The Spanish line equals its key, so only the resolver's extra locale shows that it exists. + $this->analyse([__DIR__ . '/../Fixtures/Rules/ResolvedLocaleTranslations.php'], [ + ['Translation "package::lookups.missing" has not been found.', 15], + ]); + } + + public function testNamespacedTranslationFunctionIsNotTheHelper(): void + { + $translator = new Translator(new FileLoader(new Filesystem, []), 'en'); + $translator->addNamespace('package', __DIR__ . '/../Fixtures/Rules/lang'); + + $this->translator = $translator; + + $this->analyse([__DIR__ . '/data/NamespacedTranslationFunction.php'], [ + ['Translation "package::lookups.missing" has not been found.', 21], + ]); + } + + #[DataProvider('customTranslators')] + public function testCustomTranslatorsAreNotChecked(Translator $translator): void + { + $this->translator = $translator; + + $this->analyse([__DIR__ . '/data/Translation.php'], []); + } + + /** @return iterable */ + public static function customTranslators(): iterable + { + $directory = __DIR__ . '/../application/lang'; + + yield 'custom translator' => [new class(new FileLoader(new Filesystem, $directory), 'en') extends Translator { + }]; + + yield 'custom loader' => [new Translator(new class(new Filesystem, $directory) extends FileLoader { + }, 'en')]; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-rules.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/NoModelMakeRuleTest.php b/tests/PHPStan/Rules/NoModelMakeRuleTest.php new file mode 100644 index 0000000000..f5eb3cff4f --- /dev/null +++ b/tests/PHPStan/Rules/NoModelMakeRuleTest.php @@ -0,0 +1,81 @@ + */ +class NoModelMakeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoModelMakeRule::class); + } + + public function testNoFalsePositives(): void + { + $this->analyse([ + __DIR__ . '/data/CorrectModelInstantiation.php', + __DIR__ . '/data/ModelMakeTrait.php', + ], []); + } + + public function testModelMake(): void + { + $this->analyse([__DIR__ . '/data/ModelMake.php'], [ + ["Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", 15], + ["Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", 22], + ]); + } + + public function testModelMakeOnRelativeName(): void + { + $this->analyse([__DIR__ . '/data/ModelMakeRelativeName.php'], [ + ["Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", 13], + ["Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", 18], + ]); + } + + public function testReportsModelMakeCallsInTraitRatherThanClass(): void + { + $actualErrors = $this->gatherAnalyserErrors([ + __DIR__ . '/data/ModelMake.php', + __DIR__ . '/data/ModelMakeTrait.php', + ]); + + $this->assertCount(3, $actualErrors); + $this->assertSame( + "Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", + $actualErrors[0]->getMessage(), + ); + $this->assertSame( + __DIR__ . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'ModelMakeTrait.php (in context of class Hypervel\Tests\PHPStan\Rules\Data\ModelMake)', + $actualErrors[0]->getFile(), + ); + $this->assertSame(13, $actualErrors[0]->getLine()); + } + + public function testOnlyExactPlainModelsWithFixedConnectionsAreReported(): void + { + $this->analyse([__DIR__ . '/data/no-model-make-hypervel.php'], [ + ["Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", 166], + ["Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", 167], + ["Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", 168], + ["Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", 169], + ["Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", 170], + ["Called 'Model::make()' which performs unnecessary work, use 'new Model()'.", 171], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/NoPublicModelScopeAndAccessorRuleTest.php b/tests/PHPStan/Rules/NoPublicModelScopeAndAccessorRuleTest.php new file mode 100644 index 0000000000..7696aaf696 --- /dev/null +++ b/tests/PHPStan/Rules/NoPublicModelScopeAndAccessorRuleTest.php @@ -0,0 +1,57 @@ + */ +class NoPublicModelScopeAndAccessorRuleTest extends RuleTestCase +{ + /** + * Hypervel: upstream's cases run without the legacy scope rule, which otherwise reports the legacy scopes. + */ + private bool $noLegacyModelScopes = false; + + protected function getRule(): Rule + { + return new NoPublicModelScopeAndAccessorRule(self::getContainer()->getByType(ModelRuleHelper::class), $this->noLegacyModelScopes); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/no-public-model-scope-and-accessor.php'], [ + ["Local query scope method 'scopePublic' should be declared as protected.", 19], + ["Model accessor method 'lastName' should be declared as protected.", 32], + ["Local query scope method 'publicWithAttribute' should be declared as protected.", 46], + ["Local query scope method 'scopePrivate' should be declared as protected.", 62], + ["Model accessor method 'privateAccessor' should be declared as protected.", 67], + ]); + } + + public function testFix(): void + { + // Hypervel: only the private methods are fixed, since a public method can have callers in other files. + $this->fix(__DIR__ . '/data/no-public-model-scope-and-accessor.php', __DIR__ . '/data/no-public-model-scope-and-accessor-fixed.php'); + } + + public function testLegacyScopesConvertibleToAttributedScopesAreLeftToTheLegacyScopeRule(): void + { + $this->noLegacyModelScopes = true; + + $this->analyse([__DIR__ . '/data/model-scope-methods.php'], [ + ["Local query scope method 'scopeRead' should be declared as protected.", 42], + ["Model accessor method 'summary' should be declared as protected.", 70], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/NoUnnecessaryCollectionCallRuleTest.php b/tests/PHPStan/Rules/NoUnnecessaryCollectionCallRuleTest.php new file mode 100644 index 0000000000..24b12af7e6 --- /dev/null +++ b/tests/PHPStan/Rules/NoUnnecessaryCollectionCallRuleTest.php @@ -0,0 +1,118 @@ + */ +class NoUnnecessaryCollectionCallRuleTest extends RuleTestCase +{ + /** + * Hypervel: the rule's only and except settings, which one test narrows. + * + * @var string[] + */ + private array $onlyMethods = []; + + /** @var string[] */ + private array $excludeMethods = []; + + protected function getRule(): Rule + { + $container = self::getContainer(); + + return new NoUnnecessaryCollectionCallRule( + $this->createReflectionProvider(), + $container->getByType(ModelRuleHelper::class), + $container->getByType(ModelHelper::class), + $container->getByType(ModelPropertyHelper::class), + $container->getByType(ModelScopeMethodResolver::class), + $container->getByType(CollectionHelper::class), + $container->getByType(RuntimeInputs::class), + $this->onlyMethods, + $this->excludeMethods, + ); + } + + public function testNoFalsePositives(): void + { + $this->analyse([__DIR__ . '/data/CorrectCollectionCalls.php'], []); + } + + public function testNoFalseNegativesEloquent(): void + { + // Hypervel: App\User uses SoftDeletes, whose global scope the rule cannot show leaves the result unchanged, + // so lines 18, 23, 29, 34, 39, 62, 72 and 92 are not reported. Line 45 calls a relation and line 57 a variable, + // whose queries are not in view. + // REMOVED: contains() (line 67), where() (78), diff() (84), containsStrict() (97) and sum() (103), whose query + // forms compare or aggregate values differently. + $this->analyse([__DIR__ . '/data/UnnecessaryCollectionCallsEloquent.php'], [ + ['Called \'count\' on Hypervel collection, but could have been retrieved as a query with \'count()\'.', 50], + ]); + } + + public function testNoFalseNegativesQuery(): void + { + // REMOVED: max() (line 15), which aggregates values differently. + // Hypervel: line 26 is not reported, as a DB::table() query's fetched columns are unknown. + $this->analyse([__DIR__ . '/data/UnnecessaryCollectionCallsQuery.php'], [ + ['Called \'isNotEmpty\' on Hypervel collection, but could have been retrieved as a query with \'exists()\'.', 20], + ]); + } + + public function testOnlyEquivalentQueriesAreReported(): void + { + $this->analyse([__DIR__ . '/data/no-unnecessary-collection-call-hypervel.php'], [ + ['Called \'count\' on Hypervel collection, but could have been retrieved as a query with \'count()\'.', 149], + ['Called \'count\' on Hypervel collection, but could have been retrieved as a query with \'count()\'.', 150], + ['Called \'first\' on Hypervel collection, but could have been retrieved as a query with \'first()\'.', 151], + ['Called \'first\' on Hypervel collection, but could have been retrieved as a query with \'first()\'.', 152], + ['Called \'isEmpty\' on Hypervel collection, but could have been retrieved as a query with \'doesntExist()\'.', 153], + ['Called \'isNotEmpty\' on Hypervel collection, but could have been retrieved as a query with \'exists()\'.', 154], + ['Called \'take\' on Hypervel collection, but could have been retrieved as a query with \'take()->get()\'.', 155], + ['Called \'pluck\' on Hypervel collection, but could have been retrieved as a query with \'pluck()\'.', 156], + ['Called \'pluck\' on Hypervel collection, but could have been retrieved as a query with \'pluck()\'.', 157], + ['Called \'pluck\' on Hypervel collection, but could have been retrieved as a query with \'pluck()\'.', 158], + ['Called \'modelKeys\' on Hypervel collection, but could have been retrieved as a query with \'modelKeys()\'.', 159], + ['Called \'count\' on Hypervel collection, but could have been retrieved as a query with \'count()\'.', 160], + ['Called \'count\' on Hypervel collection, but could have been retrieved as a query with \'count()\'.', 161], + ['Called \'first\' on Hypervel collection, but could have been retrieved as a query with \'first()\'.', 162], + ['Called \'isEmpty\' on Hypervel collection, but could have been retrieved as a query with \'doesntExist()\'.', 163], + ['Called \'first\' on Hypervel collection, but could have been retrieved as a query with \'first()\'.', 164], + ['Called \'count\' on Hypervel collection, but could have been retrieved as a query with \'count()\'.', 165], + ['Called \'isEmpty\' on Hypervel collection, but could have been retrieved as a query with \'doesntExist()\'.', 166], + ['Called \'count\' on Hypervel collection, but could have been retrieved as a query with \'count()\'.', 167], + ['Called \'pluck\' on Hypervel collection, but could have been retrieved as a query with \'pluck()\'.', 168], + ['Called \'modelKeys\' on Hypervel collection, but could have been retrieved as a query with \'modelKeys()\'.', 169], + ]); + } + + public function testOnlyAndExceptSettings(): void + { + $this->onlyMethods = ['First', 'COUNT']; + $this->excludeMethods = ['count']; + + $this->analyse([__DIR__ . '/data/no-unnecessary-collection-call-hypervel.php'], [ + ['Called \'first\' on Hypervel collection, but could have been retrieved as a query with \'first()\'.', 151], + ['Called \'first\' on Hypervel collection, but could have been retrieved as a query with \'first()\'.', 152], + ['Called \'first\' on Hypervel collection, but could have been retrieved as a query with \'first()\'.', 162], + ['Called \'first\' on Hypervel collection, but could have been retrieved as a query with \'first()\'.', 164], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/NoUnnecessaryEnumerableToArrayCallsRuleTest.php b/tests/PHPStan/Rules/NoUnnecessaryEnumerableToArrayCallsRuleTest.php new file mode 100644 index 0000000000..682cf91567 --- /dev/null +++ b/tests/PHPStan/Rules/NoUnnecessaryEnumerableToArrayCallsRuleTest.php @@ -0,0 +1,29 @@ + */ +class NoUnnecessaryEnumerableToArrayCallsRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new NoUnnecessaryEnumerableToArrayCallsRule; + } + + public function testRule(): void + { + $message = "Called [toArray()] on an Enumerable which does not contain any Arrayables.\n 💡 Use [all()] to get the items as an array."; + + $this->analyse([__DIR__ . '/data/unnecessary-enumerable-toArray-calls.php'], [ + [$message, 33], + [$message, 34], + [$message, 36], + ]); + } +} diff --git a/tests/PHPStan/Rules/Queue/BatchableJobChecksCancellationRuleTest.php b/tests/PHPStan/Rules/Queue/BatchableJobChecksCancellationRuleTest.php new file mode 100644 index 0000000000..dc2e9bbbd1 --- /dev/null +++ b/tests/PHPStan/Rules/Queue/BatchableJobChecksCancellationRuleTest.php @@ -0,0 +1,50 @@ + */ +class BatchableJobChecksCancellationRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(BatchableJobChecksCancellationRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/batchable-jobs.php'], [ + [ + "No batch cancellation check was found in batchable job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\BatchableJobWithoutCancellationCheck.\n 💡 Check \$this->batch()?->cancelled() or \$this->batching(), or return the SkipIfBatchCancelled middleware from middleware().", + 74, + ], + [ + "No batch cancellation check was found in batchable job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\ConcreteBatchableJobFromAbstractBase.\n 💡 Check \$this->batch()?->cancelled() or \$this->batching(), or return the SkipIfBatchCancelled middleware from middleware().", + 84, + ], + [ + "No batch cancellation check was found in batchable job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\JobOverridingCancellationGuard.\n 💡 Check \$this->batch()?->cancelled() or \$this->batching(), or return the SkipIfBatchCancelled middleware from middleware().", + 125, + ], + [ + "No batch cancellation check was found in batchable job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\JobOverridingCancellationMiddleware.\n 💡 Check \$this->batch()?->cancelled() or \$this->batching(), or return the SkipIfBatchCancelled middleware from middleware().", + 132, + ], + [ + "No batch cancellation check was found in batchable job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\JobCheckingAnotherCancellation.\n 💡 Check \$this->batch()?->cancelled() or \$this->batching(), or return the SkipIfBatchCancelled middleware from middleware().", + 157, + ], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/Queue/BatchedJobIsBatchableRuleTest.php b/tests/PHPStan/Rules/Queue/BatchedJobIsBatchableRuleTest.php new file mode 100644 index 0000000000..aa65223e32 --- /dev/null +++ b/tests/PHPStan/Rules/Queue/BatchedJobIsBatchableRuleTest.php @@ -0,0 +1,98 @@ + */ +class BatchedJobIsBatchableRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(BatchedJobIsBatchableRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/bus-batch.php'], [ + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 15, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 22, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 41, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\SyncReport is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 53, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 68, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 69, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 70, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 72, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 77, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 87, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 88, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 98, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 111, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\SyncReport|Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 113, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 114, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob|Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\SyncReport is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 126, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\RegularJob|Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\SyncReport is batched but does not use Batchable.\n 💡 Use the Hypervel\\Bus\\Batchable trait.", + 127, + ], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/Queue/JobDispatchedInTransactionUsesAfterCommitRuleTest.php b/tests/PHPStan/Rules/Queue/JobDispatchedInTransactionUsesAfterCommitRuleTest.php new file mode 100644 index 0000000000..2ceb1dabbe --- /dev/null +++ b/tests/PHPStan/Rules/Queue/JobDispatchedInTransactionUsesAfterCommitRuleTest.php @@ -0,0 +1,94 @@ + */ +class JobDispatchedInTransactionUsesAfterCommitRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + // A new rule reads the queue routes a test binds; the container's rule keeps the first routes it reads. + $container = self::getContainer(); + + return new JobDispatchedInTransactionUsesAfterCommitRule( + $container->getByType(ReflectionProvider::class), + $container->getByType(ConfigParser::class), + $container->getByType(InitializerExprTypeResolver::class), + $container->getByType(ConstructorParser::class), + $container->getByType(RuntimeInputs::class), + ); + } + + public function testRule(): void + { + $message = "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\NotifyOwner is dispatched inside a transaction without deferring until commit.\n 💡 Call afterCommit() on the dispatch, implement ShouldQueueAfterCommit, or enable after_commit on the queue connection."; + + $this->analyse([__DIR__ . '/data/transaction-dispatch.php'], [ + [$message, 44], + [$message, 48], + [$message, 52], + [$message, 55], + [$message, 78], + [str_replace('NotifyOwner', 'DatabaseQueuedJob', $message), 128], + [str_replace('NotifyOwner', 'DatabaseQueuedJob', $message), 132], + [str_replace('NotifyOwner', 'QueueableNotifyOwner', $message), 173], + [str_replace('NotifyOwner', 'QueueableNotifyOwner', $message), 179], + [str_replace('Hypervel\Tests\PHPStan\Rules\Queue\Data\NotifyOwner', 'Hypervel\Queue\CallQueuedClosure', $message), 191], + [$message, 199], + [str_replace('NotifyOwner', 'DatabaseQueuedJob', $message), 205], + [str_replace('NotifyOwner', 'SyncAttributeJob', $message), 225], + [str_replace('NotifyOwner', 'RedisPropertyJob', $message), 226], + [str_replace('NotifyOwner', 'ConstructorQueueJob', $message), 314], + [$message, 353], + [$message, 354], + [$message, 355], + ]); + } + + public function testAfterCommitContract(): void + { + // Hypervel: a deliberate beforeCommit() or $afterCommit = false is respected, unlike upstream, + // which reports both. Upstream's branch for Laravel versions before 12.22 is omitted. + $this->analyse([__DIR__ . '/data/transaction-aftercommit-contract.php'], []); + } + + public function testQueueForwardsMayChooseTheConnection(): void + { + $container = Container::getInstance(); + $routes = $container->make('queue.routes'); + $forwardingRoutes = clone $routes; + $forwardingRoutes->forward('default', connection: 'sync'); + $container->instance('queue.routes', $forwardingRoutes); + + try { + $this->analyse([__DIR__ . '/data/transaction-forwards.php'], [ + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\QueueableNotifyOwner is dispatched inside a transaction without deferring until commit.\n 💡 Call afterCommit() on the dispatch, implement ShouldQueueAfterCommit, or enable after_commit on the queue connection.", + 12, + ], + ]); + } finally { + $container->instance('queue.routes', $routes); + } + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/Queue/JobWithModelPropertyDeclaresSerializesModelsRuleTest.php b/tests/PHPStan/Rules/Queue/JobWithModelPropertyDeclaresSerializesModelsRuleTest.php new file mode 100644 index 0000000000..3fdf794f6d --- /dev/null +++ b/tests/PHPStan/Rules/Queue/JobWithModelPropertyDeclaresSerializesModelsRuleTest.php @@ -0,0 +1,59 @@ + */ +class JobWithModelPropertyDeclaresSerializesModelsRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(JobWithModelPropertyDeclaresSerializesModelsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/serializes-models.php'], [ + // Hypervel: protected and private model state is serialized too. + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\JobWithProtectedModelProperty has model properties (\$product) but does not use SerializesModels.\n 💡 Use the Hypervel\\Queue\\SerializesModels trait.", + 62, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\JobWithModelPropertyWithoutSerializesModels has model properties (\$product) but does not use SerializesModels.\n 💡 Use the Hypervel\\Queue\\SerializesModels trait.", + 69, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\JobWithNullableModelPropertyWithoutSerializesModels has model properties (\$product) but does not use SerializesModels.\n 💡 Use the Hypervel\\Queue\\SerializesModels trait.", + 76, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\JobWithMultipleModelPropertiesWithoutSerializesModels has model properties (\$product, \$invoice) but does not use SerializesModels.\n 💡 Use the Hypervel\\Queue\\SerializesModels trait.", + 81, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\JobInheritingModelProperty has model properties (\$product) but does not use SerializesModels.\n 💡 Use the Hypervel\\Queue\\SerializesModels trait.", + 88, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\JobInheritingPrivateModelProperty has model properties (\$product) but does not use SerializesModels.\n 💡 Use the Hypervel\\Queue\\SerializesModels trait.", + 106, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\JobWithModelCollectionProperty has model properties (\$products) but does not use SerializesModels.\n 💡 Use the Hypervel\\Queue\\SerializesModels trait.", + 110, + ], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/Queue/NoBatchedUniqueJobRuleTest.php b/tests/PHPStan/Rules/Queue/NoBatchedUniqueJobRuleTest.php new file mode 100644 index 0000000000..8cd13fc80f --- /dev/null +++ b/tests/PHPStan/Rules/Queue/NoBatchedUniqueJobRuleTest.php @@ -0,0 +1,82 @@ + */ +class NoBatchedUniqueJobRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoBatchedUniqueJobRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/batched-unique-jobs.php'], [ + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via batch().\n 💡 Dispatch unique jobs individually.", + 12, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForMethod is dispatched via bulk().\n 💡 Dispatch unique jobs individually.", + 17, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via bulk().\n 💡 Dispatch unique jobs individually.", + 21, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via batch().\n 💡 Dispatch unique jobs individually.", + 28, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via batch().\n 💡 Dispatch unique jobs individually.", + 44, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via batch().\n 💡 Dispatch unique jobs individually.", + 47, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForMethod is dispatched via add().\n 💡 Dispatch unique jobs individually.", + 51, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via batch().\n 💡 Dispatch unique jobs individually.", + 59, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via bulk().\n 💡 Dispatch unique jobs individually.", + 61, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via batch().\n 💡 Dispatch unique jobs individually.", + 68, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForMethod|Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via batch().\n 💡 Dispatch unique jobs individually.", + 75, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForMethod|Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via batch().\n 💡 Dispatch unique jobs individually.", + 76, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForMethod|Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithUniqueForProperty is dispatched via batch().\n 💡 Dispatch unique jobs individually.", + 77, + ], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/Queue/NoLegacyQueuePropertiesRuleTest.php b/tests/PHPStan/Rules/Queue/NoLegacyQueuePropertiesRuleTest.php new file mode 100644 index 0000000000..86427a95fe --- /dev/null +++ b/tests/PHPStan/Rules/Queue/NoLegacyQueuePropertiesRuleTest.php @@ -0,0 +1,54 @@ + */ +class NoLegacyQueuePropertiesRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoLegacyQueuePropertiesRule::class); + } + + public function testRule(): void + { + $data = 'Hypervel\Tests\PHPStan\Rules\Queue\Data\\'; + $subclasses = "Subclasses' own properties and attributes can take precedence differently: review them, and keep the property where attributes cannot express the hierarchy. See https://hypervel.org/docs/static-analysis#queue-configuration"; + $tip = "\n 💡 " . $subclasses; + + $this->analyse([__DIR__ . '/data/legacy-queue-properties.php'], [ + ['Queue property ' . $data . 'SendInvoice::$tries should be replaced by attribute #[Tries(3)].' . $tip, 27], + ['Queue property ' . $data . 'SendInvoice::$timeout should be replaced by attribute #[Timeout(120)].' . $tip, 29], + ['Queue property ' . $data . 'SendInvoice::$maxExceptions should be replaced by attribute #[MaxExceptions(2)].' . $tip, 31], + ['Queue property ' . $data . 'SendInvoice::$backoff should be replaced by attribute #[Backoff([1, 5, 10])].' . $tip, 33], + ['Queue property ' . $data . 'SendInvoice::$failOnTimeout should be replaced by attribute #[FailOnTimeout].' . $tip, 35], + ['Queue property ' . $data . 'SendInvoice::$countCrashesAsExceptions should be replaced by attribute #[CountCrashesAsExceptions].' . $tip, 37], + ['Queue property ' . $data . 'SendInvoice::$deleteWhenMissingModels should be replaced by attribute #[DeleteWhenMissingModels].' . $tip, 39], + ['Queue property ' . $data . 'SendReminder::$queue should be replaced by attribute #[Queue(\'emails\')].' . $tip, 44], + ['Queue property ' . $data . 'SendReminder::$delay should be replaced by attribute #[Delay(30)].' . $tip, 46], + ['Queue property ' . $data . 'SendReminder::$tries should be removed: the same value applies without it.' . $tip, 49], + ['Queue property ' . $data . 'SendWelcome::$connection should be replaced by attribute #[Connection(\Hypervel\Tests\PHPStan\Rules\Queue\Data\QueueConnection::Redis)].' . $tip, 54], + ['Queue property ' . $data . 'ImportFeed::$uniqueFor should be replaced by attribute #[UniqueFor(3600)].' . $tip, 65], + ["Queue property {$data}RebuildSearchIndex::\$debounceFor should be replaced by attribute #[DebounceFor(10, maxWait: 300)].\n 💡 • {$subclasses}\n• A subclass's own #[DebounceFor] replaces this one entirely, so it must repeat the maximum wait it inherits.", 75], + ['Queue property ' . $data . 'SyncAccount::$tries should be removed: the same value applies without it.' . $tip, 82], + ['Queue property $tries of trait ' . $data . 'RetriesThreeTimes should be replaced by attribute #[Tries(3)] on the trait.' . $tip, 119], + ['Queue property ' . $data . 'AliasedConstructorJob::$tries should be replaced by attribute #[Tries(2)].' . $tip, 150], + ['Queue property ' . $data . 'InvoiceMail::$tries should be replaced by attribute #[Tries(3)].' . $tip, 155], + ['Queue property ' . $data . 'InvoicePaid::$timeout should be replaced by attribute #[Timeout(60)].' . $tip, 167], + ['Queue property ' . $data . 'OrderShipped::$queue should be replaced by attribute #[Queue(\'broadcasts\')].' . $tip, 177], + ['Queue property ' . $data . 'SendDigest::$tries should be replaced by attribute #[Tries(4)].' . $tip, 205], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/Queue/UniqueJobDeclaresUniqueForRuleTest.php b/tests/PHPStan/Rules/Queue/UniqueJobDeclaresUniqueForRuleTest.php new file mode 100644 index 0000000000..a07cf9be41 --- /dev/null +++ b/tests/PHPStan/Rules/Queue/UniqueJobDeclaresUniqueForRuleTest.php @@ -0,0 +1,46 @@ + */ +class UniqueJobDeclaresUniqueForRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(UniqueJobDeclaresUniqueForRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/unique-jobs.php'], [ + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithoutUniqueFor implements ShouldBeUnique but does not declare uniqueFor.\n 💡 Add a #[UniqueFor] attribute, or a public uniqueFor() method for a computed duration.", + 48, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueUntilProcessingJob implements ShouldBeUnique but does not declare uniqueFor.\n 💡 Add a #[UniqueFor] attribute, or a public uniqueFor() method for a computed duration.", + 109, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithProtectedUniqueForMethod declares uniqueFor() but does not make it public.\n 💡 Make uniqueFor() public so the unique lock can call it.", + 138, + ], + [ + "Job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueJobWithProtectedUniqueForProperty declares \$uniqueFor but does not make it public.\n 💡 Make \$uniqueFor public so the unique lock can read it, or use a #[UniqueFor] attribute.", + 147, + ], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/Queue/UniqueJobDeclaresUniqueIdRuleTest.php b/tests/PHPStan/Rules/Queue/UniqueJobDeclaresUniqueIdRuleTest.php new file mode 100644 index 0000000000..ec72827559 --- /dev/null +++ b/tests/PHPStan/Rules/Queue/UniqueJobDeclaresUniqueIdRuleTest.php @@ -0,0 +1,46 @@ + */ +class UniqueJobDeclaresUniqueIdRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(UniqueJobDeclaresUniqueIdRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/unique-jobs.php'], [ + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\ParameterizedUniqueJobWithoutUniqueId has constructor parameters but does not declare uniqueId.\n 💡 Declare a public uniqueId() method or a public \$uniqueId property to identify distinct jobs.", + 66, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\UniqueUntilProcessingJob has constructor parameters but does not declare uniqueId.\n 💡 Declare a public uniqueId() method or a public \$uniqueId property to identify distinct jobs.", + 109, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\ParameterizedUniqueJobWithPrivateUniqueIdMethod declares uniqueId() but does not make it public.\n 💡 Make uniqueId() public so the unique lock can call it.", + 152, + ], + [ + "Unique job Hypervel\\Tests\\PHPStan\\Rules\\Queue\\Data\\ParameterizedUniqueJobWithProtectedUniqueIdProperty declares \$uniqueId but does not make it public.\n 💡 Make \$uniqueId public so the unique lock can read it.", + 168, + ], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Rules/Queue/data/batchable-jobs.php b/tests/PHPStan/Rules/Queue/data/batchable-jobs.php new file mode 100644 index 0000000000..22be630883 --- /dev/null +++ b/tests/PHPStan/Rules/Queue/data/batchable-jobs.php @@ -0,0 +1,228 @@ +batch()?->cancelled()) { + return; + } + } +} + +class BatchableJobWithSkipMiddleware implements ShouldQueue +{ + use Batchable; + + /** @return list */ + public function middleware(): array + { + return [new SkipIfBatchCancelled()]; + } + + public function handle(): void + { + } +} + +class BatchableJobBase implements ShouldQueue +{ + use Batchable; + + public function handle(): void + { + if ($this->batch()?->cancelled()) { + return; + } + } +} + +class BatchableJobSubclass extends BatchableJobBase +{ +} + +class BatchableJobWithoutCancellationCheck implements ShouldQueue +{ + use Batchable; + use Dispatchable; + + public function handle(): void + { + } +} + +class ConcreteBatchableJobFromAbstractBase extends AbstractBatchableJob +{ +} + +abstract class AbstractBatchableJobWithMiddleware implements ShouldQueue +{ + use Batchable; + + /** @return list */ + public function middleware(): array + { + return [new SkipIfBatchCancelled()]; + } +} + +class JobInheritingCancellationMiddleware extends AbstractBatchableJobWithMiddleware +{ + public function handle(): void + { + } +} + +trait CancellationMiddleware +{ + /** @return list */ + public function middleware(): array + { + return [new SkipIfBatchCancelled()]; + } +} + +class JobUsingCancellationMiddlewareTrait implements ShouldQueue +{ + use Batchable; + use CancellationMiddleware; + + public function handle(): void + { + } +} + +class JobOverridingCancellationGuard extends BatchableJobBase +{ + public function handle(): void + { + } +} + +class JobOverridingCancellationMiddleware extends BatchableJobWithSkipMiddleware +{ + /** @return list */ + public function middleware(): array + { + return []; + } +} + +class JobOverridingHandleWithInheritedMiddleware extends AbstractBatchableJobWithMiddleware +{ + public function handle(): void + { + } +} + +// Hypervel: checks are recognized by type, so a coincidental cancelled() on another object is not one. +class Order +{ + public function cancelled(): bool + { + return false; + } +} + +class JobCheckingAnotherCancellation implements ShouldQueue +{ + use Batchable; + + public function __construct(private Order $order) + { + } + + public function handle(): void + { + if ($this->order->cancelled()) { + return; + } + } +} + +class JobCheckingStoredBatch implements ShouldQueue +{ + use Batchable; + + public function handle(): void + { + $batch = $this->batch(); + + if ($batch?->canceled()) { + return; + } + } +} + +class JobCheckingBatching implements ShouldQueue +{ + use Batchable; + + public function handle(): void + { + if (! $this->batching()) { + return; + } + } +} + +class JobNamingCancellationMiddleware implements ShouldQueue +{ + use Batchable; + + /** @return list */ + public function middleware(): array + { + return [SkipIfBatchCancelled::class]; + } + + public function handle(): void + { + } +} + +trait ChecksBatchCancellation +{ + public function handle(): void + { + if ($this->batch()?->cancelled()) { + return; + } + } +} + +class JobUsingCancellationCheckTrait implements ShouldQueue +{ + use Batchable; + use ChecksBatchCancellation; +} diff --git a/tests/PHPStan/Rules/Queue/data/batched-unique-jobs.php b/tests/PHPStan/Rules/Queue/data/batched-unique-jobs.php new file mode 100644 index 0000000000..90be1c896d --- /dev/null +++ b/tests/PHPStan/Rules/Queue/data/batched-unique-jobs.php @@ -0,0 +1,78 @@ + $jobs */ +$jobs = []; + +Bus::batch($jobs); + +// Hypervel: a single job is wrapped into a batch, and adding to a batch bypasses the lock too. +Bus::batch(new UniqueJobWithUniqueForProperty()); + +function addUniqueJobToBatch(Batch $batch): void +{ + $batch->add([new UniqueJobWithUniqueForMethod()]); +} + +// Hypervel: a chain held in a variable is inspected too, and the jobs argument binds by name. +function batchAndBulkUniqueJobs(): void +{ + $chain = [new UniqueJobWithUniqueForProperty()]; + + Bus::batch([$chain]); + Queue::bulk(data: new UniqueJobWithUniqueForProperty(), jobs: [new RegularJob(1)]); + Queue::bulk(data: '', jobs: [new UniqueJobWithUniqueForProperty()]); +} + +// Hypervel: an entry that may be a job or a chain is reported when any job it may hold is unique. +/** @param RegularJob|list $entry */ +function batchUniqueJobOrChainEntry(RegularJob|array $entry): void +{ + Bus::batch([$entry]); +} + +// Hypervel: a choice between chains or batch lists is reported once, naming the unique jobs it may hold. +/** @param array{RegularJob}|array{UniqueJobWithUniqueForMethod, UniqueJobWithUniqueForProperty} $chains */ +function batchUniqueChainChoice(array $chains): void +{ + Bus::batch([$chains]); + Bus::batch($chains); + Bus::batch([...$chains]); +} diff --git a/tests/PHPStan/Rules/Queue/data/bus-batch.php b/tests/PHPStan/Rules/Queue/data/bus-batch.php new file mode 100644 index 0000000000..d24c5a4009 --- /dev/null +++ b/tests/PHPStan/Rules/Queue/data/bus-batch.php @@ -0,0 +1,128 @@ + $jobs */ +$jobs = []; + +Bus::batch($jobs); + +// Hypervel: PendingBatch requires the trait on every job except a closure or a nested batch, while +// a job typed by an interface may be one that uses it. +class SyncReport +{ + public function handle(): void + { + } +} + +Bus::batch([ + new SyncReport(), + static function (): void { + }, +]); + +function batchQueuedJob(ShouldQueue $job): void +{ + Bus::batch([$job]); +} + +// Hypervel: a single job, spread jobs, a dispatcher instance and adding to a pending or running batch +// need the trait too. Bulk dispatch is not a batch. +/** @param list $regularJobs */ +function batchJobs(QueueingDispatcher $dispatcher, PendingBatch $pendingBatch, ?Batch $batch, array $regularJobs): void +{ + Bus::batch(new RegularJob(3)); + $dispatcher->batch([new RegularJob(4)]); + $pendingBatch->add(new RegularJob(5)); + $batch?->add([ + new RegularJob(6), + static function (): void { + }, + ]); + Bus::bulk([new RegularJob(7)]); + Bus::batch([...$regularJobs]); +} + +// Hypervel: a chain held in a variable needs the trait too, and each job of a list held in a +// variable is checked on its own. +function batchVariables(): void +{ + $chain = [new RegularJob(8)]; + $jobs = [new BatchableJobWithCancellationCheck(), new RegularJob(9), new RegularJob(10)]; + + Bus::batch([$chain]); + Bus::batch($jobs); +} + +// Hypervel: an entry that may be a job or a chain is reported when every job it may hold lacks the trait. +/** + * @param RegularJob|list $regularEntry + * @param BatchableJobWithCancellationCheck|list $mixedEntry + */ +function batchJobOrChainEntries(RegularJob|array $regularEntry, BatchableJobWithCancellationCheck|array $mixedEntry): void +{ + Bus::batch([$regularEntry]); + Bus::batch([$mixedEntry]); +} + +// Hypervel: each path an entry may take is followed, and an entry is reported once. +/** + * @param RegularJob|array{BatchableJobWithCancellationCheck, RegularJob} $chainedEntry + * @param array{RegularJob}|array{BatchableJobWithCancellationCheck} $chainChoice + * @param array{SyncReport}|array{BatchableJobWithCancellationCheck, RegularJob} $nonBatchableChains + * @param RegularJob|array{BatchableJobWithCancellationCheck, RegularJob} $jobOrJobs + */ +function batchEntryPaths(RegularJob|array $chainedEntry, array $chainChoice, array $nonBatchableChains, RegularJob|array $jobOrJobs): void +{ + Bus::batch([$chainedEntry]); + Bus::batch([$chainChoice]); + Bus::batch([$nonBatchableChains]); + Bus::batch($jobOrJobs); +} + +// Hypervel: a batch that may be one of several lists is reported only when every list holds a job without the trait. +/** + * @param array{RegularJob, RegularJob}|array{BatchableJobWithCancellationCheck} $choices + * @param array{RegularJob}|array{SyncReport} $nonBatchableChoices + */ +function batchListChoices(array $choices, array $nonBatchableChoices): void +{ + Bus::batch($choices); + Bus::batch([...$choices]); + Bus::batch($nonBatchableChoices); + Bus::batch([...$nonBatchableChoices]); +} diff --git a/tests/PHPStan/Rules/Queue/data/legacy-queue-properties.php b/tests/PHPStan/Rules/Queue/data/legacy-queue-properties.php new file mode 100644 index 0000000000..c044e9f3eb --- /dev/null +++ b/tests/PHPStan/Rules/Queue/data/legacy-queue-properties.php @@ -0,0 +1,231 @@ +tries = $wrapped->tries ?? $this->tries; + } +} + +class WrapperChildJob extends WrapperJob +{ + // WrapperJob's constructor replaces the value this declares. + public $tries = 5; +} + +trait RetriesThreeTimes +{ + public $tries = 3; +} + +class RetriedJob implements ShouldQueue +{ + use RetriesThreeTimes; +} + +trait TimesOutAfterAMinute +{ + // @phpstan-ignore hypervel.legacyQueueProperty (fixture: one ignore beside a trait's declaration covers every class using it) + public $timeout = 60; +} + +class ImportOrders implements ShouldQueue +{ + use TimesOutAfterAMinute; +} + +class ImportRefunds implements ShouldQueue +{ + use TimesOutAfterAMinute; +} + +trait ResetsTries { public function resetTries(): void { $this->tries = null; } public function boot(): void { } } + +class AliasedConstructorJob implements ShouldQueue +{ + // The constructor is boot(), which writes nothing; resetTries() on the same line is not run. + use ResetsTries { boot as __construct; } + + public $tries = 2; +} + +class InvoiceMail extends Mailable +{ + public $tries = 3; + + public $backoff = 5; + + public function backoff(): int + { + return 5; + } +} + +class InvoicePaid extends Notification implements ShouldQueue +{ + public $timeout = 60; +} + +class InvoiceViewed extends Notification +{ + public $timeout = 60; +} + +class OrderShipped implements ShouldBroadcast +{ + public $queue = 'broadcasts'; + + public function broadcastOn(): array|Channel|string + { + return 'orders'; + } +} + +class OrderPacked implements ShouldBroadcastNow +{ + public $queue = 'broadcasts'; + + public function broadcastOn(): array|Channel|string + { + return 'orders'; + } +} + +class NotQueued +{ + public $tries = 3; +} + +class SendDigest extends SendInvoice +{ + // The constructor's onConnection() replaces this value. + public ?string $connection = 'fixed'; + + public $tries = 4; + + public function __construct() + { + $this->onConnection(null); + } +} + +trait RetriesPerClass +{ + // self::TRIES is each using class's constant, which an attribute on the trait cannot read. + public $tries = self::TRIES; +} + +class ImportContacts implements ShouldQueue +{ + use RetriesPerClass; + + public const int TRIES = 3; +} + +class ImportDeals implements ShouldQueue +{ + use RetriesPerClass; + + public const int TRIES = 5; +} diff --git a/tests/PHPStan/Rules/Queue/data/serializes-models.php b/tests/PHPStan/Rules/Queue/data/serializes-models.php new file mode 100644 index 0000000000..ba5cd78b3e --- /dev/null +++ b/tests/PHPStan/Rules/Queue/data/serializes-models.php @@ -0,0 +1,135 @@ + $products */ + public function __construct(public EloquentCollection $products) + { + } +} + +class JobWithOwnSerialization implements ShouldQueue +{ + public function __construct(public Product $product) + { + } + + /** @return array{product: mixed} */ + public function __serialize(): array + { + return ['product' => $this->product->getKey()]; + } + + /** @param array{product: mixed} $data */ + public function __unserialize(array $data): void + { + $this->product = Product::query()->findOrFail($data['product']); + } +} diff --git a/tests/PHPStan/Rules/Queue/data/transaction-aftercommit-contract.php b/tests/PHPStan/Rules/Queue/data/transaction-aftercommit-contract.php new file mode 100644 index 0000000000..c7daeb956e --- /dev/null +++ b/tests/PHPStan/Rules/Queue/data/transaction-aftercommit-contract.php @@ -0,0 +1,29 @@ +beforeCommit(); + AfterCommitContractJob::dispatch()->beforeCommit()->afterCommit(); + BeforeCommitContractJob::dispatch(); + BeforeCommitContractJob::dispatch()->afterCommit(); +}); diff --git a/tests/PHPStan/Rules/Queue/data/transaction-dispatch.php b/tests/PHPStan/Rules/Queue/data/transaction-dispatch.php new file mode 100644 index 0000000000..50e32c04ca --- /dev/null +++ b/tests/PHPStan/Rules/Queue/data/transaction-dispatch.php @@ -0,0 +1,359 @@ +onQueue('default'); +}); + +DB::transaction(static fn (): mixed => NotifyOwner::dispatch(1)); + +// Not flagged: the dispatch is deferred explicitly. +DB::transaction(static function (): void { + NotifyOwner::dispatch(1)->afterCommit(); +}); + +DB::transaction(static function (): void { + NotifyOwner::dispatch(1)->onQueue('default')->afterCommit(); +}); + +// Not flagged: the job opts in for every dispatch. +DB::transaction(static function (): void { + NotifyOwnerAfterCommit::dispatch(1); +}); + +// Not flagged: synchronous dispatch runs inline, inside the transaction. +DB::transaction(static function (): void { + NotifyOwner::dispatchSync(1); +}); + +// Hypervel: unlike upstream, Bus::dispatch() is flagged: it queues the job the same way. +DB::transaction(static function (): void { + Bus::dispatch(new NotifyOwner(1)); +}); + +// Not flagged: a non queued dispatchable runs synchronously. +DB::transaction(static function (): void { + PlainDispatchable::dispatch(); +}); + +// Not flagged: outside any transaction. +NotifyOwner::dispatch(1); + +class NotifyOwnerUsingAfterCommitContract implements ShouldQueueAfterCommit +{ + use Dispatchable; +} + +class QueueableNotifyOwner implements ShouldQueue +{ + use Dispatchable; + use Queueable; +} + +class QueueableNotifyOwnerAfterCommit extends QueueableNotifyOwner +{ + public ?bool $afterCommit = true; +} + +DB::transaction(static function (): void { + NotifyOwnerUsingAfterCommitContract::dispatch(1); + dispatch(new NotifyOwnerUsingAfterCommitContract()); +}); + +// Hypervel: unlike upstream, a final beforeCommit() is not flagged: it is a supported way to +// dispatch inside the transaction. +DB::transaction(static function (): void { + QueueableNotifyOwner::dispatch()->afterCommit()->beforeCommit(); + QueueableNotifyOwner::dispatch()->beforeCommit()->afterCommit(); + QueueableNotifyOwnerAfterCommit::dispatch()->beforeCommit(); + QueueableNotifyOwnerAfterCommit::dispatch(); +}); + +// Hypervel: the connection and driver decide whether the queue waits for the commit. +#[Connection('transaction-database')] +class DatabaseQueuedJob implements ShouldQueue +{ + use Dispatchable; + use Queueable; +} + +DB::connection('mysql')->transaction(static function (): void { + DatabaseQueuedJob::dispatch(); +}); + +DB::connection('pgsql::write')->transaction(static function (): void { + DatabaseQueuedJob::dispatch(); +}); + +// Not flagged: the job is inserted inside the transaction on the queue's own connection. +DB::connection('pgsql')->transaction(static function (): void { + DatabaseQueuedJob::dispatch(); +}); + +// Not flagged: role aliases of an SQLite connection may share one in-memory database. +DB::connection('sqlite::write')->transaction(static function (): void { + QueueableNotifyOwner::dispatch()->onConnection('transaction-sqlite'); +}); + +// Not flagged: the connection waits for the commit, by its configuration or its driver's default. +DB::transaction(static function (): void { + QueueableNotifyOwner::dispatch()->onConnection('redis'); + QueueableNotifyOwner::dispatch()->onConnection('transaction-redis-default'); +}); + +// Not flagged: synchronous, failover and environment-dependent connections. +DB::transaction(static function (): void { + QueueableNotifyOwner::dispatch()->onConnection('sync'); + QueueableNotifyOwner::dispatch()->onConnection('failover'); +}); + +DB::connection('mysql')->transaction(static function (): void { + QueueableNotifyOwner::dispatch()->onConnection('database'); +}); + +// The outbox stages the job inside the transaction. A dispatch that opts out is flagged. +class OutboxJob implements ShouldQueueViaOutbox +{ + use Dispatchable; + use Queueable; +} + +DB::transaction(static function (): void { + QueueableNotifyOwner::dispatch()->viaOutbox(); + QueueableNotifyOwner::dispatch()->onOutboxDatabase('pgsql'); + QueueableNotifyOwner::dispatch()->onConnection('transaction-outbox'); + OutboxJob::dispatch(); + QueueableNotifyOwner::dispatch()->onConnection('transaction-outbox')->withoutOutbox(); +}); + +// afterResponse() runs the job synchronously once the response is sent; afterResponse(false) queues it. +DB::transaction(static function (): void { + QueueableNotifyOwner::dispatch()->afterResponse(); + QueueableNotifyOwner::dispatch()->afterResponse(false); +}); + +// Not flagged: a callback run after the commit, and a job the call does not name. +DB::transaction(static function (): void { + DB::afterCommit(static fn (): mixed => NotifyOwner::dispatch(1)); + $job = new NotifyOwner(1); + dispatch($job); +}); + +// The helper queues a closure as a job of its own. +DB::transaction(static function (): void { + dispatch(static function (): void { + }); +}); + +// A nested transaction is checked from its own call, where the database queue may share the outer one. +DB::connection('mysql')->transaction(static function (): void { + DB::connection('mysql')->transaction(static function (): void { + DatabaseQueuedJob::dispatch(); + NotifyOwner::dispatch(1); + }); +}); + +DB::afterCommit(static function (): void { + DB::connection('mysql')->transaction(static function (): void { + DatabaseQueuedJob::dispatch(); + }); +}); + +// A chained connection replaces the attribute, and a public property declared below the +// attribute's class replaces it too. +#[Connection('sync')] +class SyncAttributeJob implements ShouldQueue +{ + use Dispatchable; + use Queueable; +} + +class RedisPropertyJob extends SyncAttributeJob +{ + public ?string $connection = 'transaction-redis'; +} + +DB::transaction(static function (): void { + SyncAttributeJob::dispatch(); + SyncAttributeJob::dispatch()->onConnection('transaction-redis'); + RedisPropertyJob::dispatch(); +}); + +// A constructor that may change the settings leaves them unknown, including through a fluent chain +// or a parent constructor calling the job's own override. Queueable's other setters do not. +class ConstructorConnectionJob implements ShouldQueue +{ + use Dispatchable; + use Queueable; + + public function __construct() + { + $this->onConnection('sync'); + } +} + +class ConstructorHelperJob implements ShouldQueue +{ + use Dispatchable; + use Queueable; + + public function __construct() + { + $this->configure(); + } + + private function configure(): void + { + } +} + +class ConstructorAfterCommitJob extends QueueableNotifyOwner +{ + public function __construct() + { + $this->afterCommit = true; + } +} + +class ParentConstructorJob extends ConstructorAfterCommitJob +{ + public function __construct() + { + parent::__construct(); + } +} + +class ConstructorQueueJob implements ShouldQueue +{ + use Dispatchable; + use Queueable; + + public function __construct() + { + $this->onQueue('high'); + } +} + +class FluentConstructorJob implements ShouldQueue +{ + use Dispatchable; + use Queueable; + + public function __construct() + { + $this->onQueue('high')->onConnection('sync'); + } +} + +class OverriddenSetterJob extends ConstructorQueueJob +{ + public function __construct() + { + parent::__construct(); + } + + public function onQueue(mixed $queue): static + { + $this->connection = 'sync'; + + return $this; + } +} + +DB::transaction(static function (): void { + ConstructorConnectionJob::dispatch(); + ConstructorHelperJob::dispatch(); + ParentConstructorJob::dispatch(); + ConstructorQueueJob::dispatch(); + FluentConstructorJob::dispatch(); + OverriddenSetterJob::dispatch(); +}); + +// Not flagged: a booted route may choose the connection. +class RoutedJob implements ShouldQueue +{ + use Dispatchable; + use Queueable; +} + +DB::transaction(static function (): void { + RoutedJob::dispatch(); +}); + +// Not flagged: the enclosing transaction stays known after a statement that invalidates an expression. +class TrackedOrder +{ + public string $status = 'new'; + + public function save(): void + { + } +} + +$order = new TrackedOrder(); + +DB::connection('pgsql')->transaction(static function () use ($order): void { + $order->status = 'paid'; + $order->save(); + + DB::connection('mysql')->transaction(static function (): void { + DatabaseQueuedJob::dispatch(); + }); +}); + +// Hypervel: conditional dispatches are flagged too, unless a literal condition never dispatches. +DB::transaction(static function () use ($order): void { + NotifyOwner::dispatchIf(true, 1); + NotifyOwner::dispatchUnless(false, 1); + NotifyOwner::dispatchIf(boolean: $order->status === 'paid', productId: 1); + NotifyOwner::dispatchIf(false, 1); + NotifyOwner::dispatchUnless(true, 1); + NotifyOwner::dispatchIf(true, 1)->afterCommit(); +}); diff --git a/tests/PHPStan/Rules/Queue/data/transaction-forwards.php b/tests/PHPStan/Rules/Queue/data/transaction-forwards.php new file mode 100644 index 0000000000..b870fadca6 --- /dev/null +++ b/tests/PHPStan/Rules/Queue/data/transaction-forwards.php @@ -0,0 +1,13 @@ +onConnection('transaction-redis'); +}); diff --git a/tests/PHPStan/Rules/Queue/data/unique-jobs.php b/tests/PHPStan/Rules/Queue/data/unique-jobs.php new file mode 100644 index 0000000000..890c363591 --- /dev/null +++ b/tests/PHPStan/Rules/Queue/data/unique-jobs.php @@ -0,0 +1,177 @@ +companyId; + } +} + +class ParameterizedUniqueJobWithoutUniqueId implements ShouldQueue, ShouldBeUnique +{ + public int $uniqueFor = 3600; + + public function __construct(public int $companyId) + { + } +} + +class ParameterizedUniqueJobWithUniqueIdMethod implements ShouldQueue, ShouldBeUnique +{ + public int $uniqueFor = 3600; + + public function __construct(public int $companyId) + { + } + + public function uniqueId(): string + { + return (string) $this->companyId; + } +} + +class ParameterizedUniqueJobWithUniqueIdProperty implements ShouldQueue, ShouldBeUnique +{ + public int $uniqueFor = 3600; + + public string $uniqueId = 'fixed'; + + public function __construct(public int $companyId) + { + } +} + +class UniqueJobWithParameterlessConstructor implements ShouldQueue, ShouldBeUnique +{ + public int $uniqueFor = 3600; + + public function __construct() + { + } +} + +class UniqueUntilProcessingJob implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + public function __construct(public int $companyId) + { + } +} + +// Hypervel: #[UniqueFor] declares the duration, read from a parent or a used trait as the lock reads it. +#[UniqueFor(3600)] +abstract class AbstractUniqueJobWithUniqueForAttribute implements ShouldQueue, ShouldBeUnique +{ +} + +class UniqueJobInheritingUniqueForAttribute extends AbstractUniqueJobWithUniqueForAttribute +{ +} + +#[UniqueFor(3600)] +trait UniqueForAnHour +{ +} + +class UniqueJobUsingUniqueForTrait implements ShouldQueue, ShouldBeUnique +{ + use UniqueForAnHour; +} + +// Hypervel: the unique lock calls a uniqueFor() or uniqueId() method from outside the job, so the +// method wins and must be public. It reads only a public property. +#[UniqueFor(3600)] +class UniqueJobWithProtectedUniqueForMethod implements ShouldQueue, ShouldBeUnique +{ + protected function uniqueFor(): int + { + return 3600; + } +} + +class UniqueJobWithProtectedUniqueForProperty implements ShouldQueue, ShouldBeUnique +{ + protected int $uniqueFor = 3600; +} + +class ParameterizedUniqueJobWithPrivateUniqueIdMethod implements ShouldQueue, ShouldBeUnique +{ + public int $uniqueFor = 3600; + + public string $uniqueId = 'fixed'; + + public function __construct(public int $companyId) + { + } + + private function uniqueId(): string + { + return 'fixed'; + } +} + +class ParameterizedUniqueJobWithProtectedUniqueIdProperty implements ShouldQueue, ShouldBeUnique +{ + public int $uniqueFor = 3600; + + protected string $uniqueId = 'fixed'; + + public function __construct(public int $companyId) + { + } +} diff --git a/tests/PHPStan/Rules/RedisConnectionBoundMethodRuleTest.php b/tests/PHPStan/Rules/RedisConnectionBoundMethodRuleTest.php new file mode 100644 index 0000000000..a4471c8374 --- /dev/null +++ b/tests/PHPStan/Rules/RedisConnectionBoundMethodRuleTest.php @@ -0,0 +1,46 @@ + */ +class RedisConnectionBoundMethodRuleTest extends RuleTestCase +{ + private const string MESSAGE = 'Redis connection method %s() must be called on a held Redis connection. Use Redis::withConnection() or Redis::withPinnedConnection().'; + + protected function getRule(): Rule + { + return new RedisConnectionBoundMethodRule; + } + + #[Test] + public function itReportsConnectionBoundMethodsThatReachTheProxy(): void + { + $this->analyse([__DIR__ . '/data/redis-connection-bound-method.php'], [ + [sprintf(self::MESSAGE, 'client'), 36], + [sprintf(self::MESSAGE, 'AUTH'), 37], + [sprintf(self::MESSAGE, 'release'), 38], + [sprintf(self::MESSAGE, 'getConnection'), 39], + ]); + } + + #[Test] + public function itAllowsTheProxysOwnProtectedMethodCalls(): void + { + $this->analyse([__DIR__ . '/../../../src/redis/src/RedisProxy.php'], []); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/RelationExistenceRuleTest.php b/tests/PHPStan/Rules/RelationExistenceRuleTest.php new file mode 100644 index 0000000000..39f99a9a2d --- /dev/null +++ b/tests/PHPStan/Rules/RelationExistenceRuleTest.php @@ -0,0 +1,347 @@ + */ +class RelationExistenceRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(RelationExistenceRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/relation-existence-rule.php'], [ + [ + 'Relation \'foo\' is not found in App\User model.', + 5, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 6, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 7, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 8, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 9, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 10, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 11, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 12, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 13, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 15, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 16, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 17, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 18, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 19, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 20, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 21, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 22, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 23, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 25, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 26, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 27, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 28, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 29, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 30, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 31, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 32, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 33, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 35, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 36, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 37, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 38, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 39, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 40, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 41, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 42, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 43, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 45, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 46, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 47, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 48, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 49, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 49, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 50, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 53, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 54, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 55, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 56, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 59, + ], + [ + 'Relation \'bar\' is not found in App\User model.', + 59, + ], + [ + 'Relation \'foo\' is not found in App\User model.', + 61, + ], + [ + 'Relation \'foo\' is not found in App\Group model.', + 62, + ], + [ + 'Relation \'foo\' is not found in App\Account model.', + 63, + ], + [ + 'Relation \'foo\' is not found in App\Transaction model.', + 64, + ], + ]); + } + + public function testBareRelationPaths(): void + { + $this->analyse([__DIR__ . '/data/relation-existence-bare-relations.php'], [ + ["Relation 'missing' is not found in App\\Comment model.", 23], + ["Relation 'labelsOfType' is not found in App\\BareRelations\\Shelf model.", 28], + ["Relation 'labelsOfKind' is not found in App\\BareRelations\\Shelf model.", 28], + ["Relation 'title' is not found in App\\BareRelations\\Shelf model.", 28], + ["Relation 'getKey' is not found in App\\BareRelations\\Shelf model.", 28], + ["Relation 'where' is not found in App\\BareRelations\\Shelf model.", 28], + ]); + } + + public function testLoading(): void + { + $this->analyse([__DIR__ . '/data/relation-existence-loading.php'], [ + ["Relation 'missing' is not found in App\\User model.", 14], + ["Relation 'missing' is not found in App\\Account model.", 15], + ["Relation 'missing' is not found in App\\User model.", 16], + ["Relation 'missing' is not found in App\\Account model.", 17], + ["Relation 'missing' is not found in App\\User model.", 18], + ["Relation 'missing' is not found in App\\User model.", 19], + ["Relation 'missing' is not found in App\\User model.", 20], + ["Relation 'missing' is not found in App\\User model.", 21], + ["Relation 'missing' is not found in App\\User model.", 22], + ["Relation 'missing' is not found in App\\User model.", 23], + ["Relation 'missing' is not found in App\\User model.", 24], + ["Relation 'missing' is not found in App\\User model.", 25], + ["Relation 'missing' is not found in App\\User model.", 26], + ["Relation 'missing' is not found in App\\User model.", 27], + ["Relation 'missing' is not found in App\\User model.", 28], + ["Relation 'missing' is not found in App\\User model.", 29], + ["Relation 'missing' is not found in App\\Account model.", 30], + ["Relation 'missing' is not found in App\\User model.", 31], + ["Relation 'missing' is not found in App\\User model.", 32], + ["Relation 'missing' is not found in App\\User model.", 33], + ["Relation 'missing' is not found in App\\User model.", 34], + ["Relation 'missing' is not found in App\\User model.", 35], + ["Relation 'accounts.transactions' is not found in App\\User model.", 36], + ["Relation 'accounts as total' is not found in App\\User model.", 37], + ["Relation 'accounts as total' is not found in App\\User model.", 38], + ["Relation 'missing' is not found in App\\User model.", 64], + ["Relation 'missing' is not found in App\\User model.", 65], + ["Relation 'missing' is not found in App\\User model.", 66], + ["Relation 'missing' is not found in App\\User model.", 67], + ["Relation 'missing' is not found in App\\User model.", 68], + ["Relation 'missing' is not found in App\\User model.", 69], + ["Relation 'missing' is not found in App\\User model.", 70], + ["Relation 'missing' is not found in App\\User model.", 71], + ["Relation 'missing' is not found in App\\User model.", 72], + ["Relation 'missing' is not found in App\\User model.", 73], + ["Relation 'missing' is not found in App\\User model.", 74], + ["Relation 'missing' is not found in App\\User model.", 75], + ["Relation 'missing' is not found in App\\User model.", 76], + ["Relation 'missing' is not found in App\\User model.", 77], + ["Relation 'missing' is not found in App\\User model.", 78], + ["Relation 'missing' is not found in App\\User model.", 79], + ["Relation 'missing' is not found in App\\User model.", 80], + ["Relation 'missing' is not found in App\\User model.", 81], + ["Relation 'missing' is not found in App\\User model.", 82], + ["Relation 'missing' is not found in App\\User model.", 83], + ["Relation 'missing' is not found in App\\User model.", 84], + ["Relation 'missing' is not found in App\\User model.", 85], + ["Relation 'missing' is not found in App\\User model.", 86], + ["Relation 'save' is not found in App\\User model.", 87], + ["Relation 'missing' is not found in App\\User model.", 112], + ["Relation 'missing' is not found in App\\Comment model.", 119], + ]); + } + + public function testColumnSelection(): void + { + $this->analyse([__DIR__ . '/data/relation-existence-column-selection.php'], [ + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 18], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 19], + ["Relation 'transactions:id' is not found in App\\Account model.", 20], + ["Relation 'accounts:id' is not found in App\\User model.", 21], + ["Relation 'transactions:id' is not found in App\\Account model.", 22], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 23], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 24], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 25], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 26], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 27], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 28], + ["Relation 'accounts:id,user_id' is not found in App\\User model.", 29], + ["Relation 'transactions:id' is not found in App\\Account model.", 30], + // Hypervel: withWhereHas() parses the column selection with a callback too, so lines 31 and 32 are valid. + ["Relation 'accounts:id' is not found in App\\User model.", 33], + ]); + } + + public function testHypervelReceiversAndNameForms(): void + { + $this->analyse([__DIR__ . '/data/relation-existence-hypervel.php'], [ + ["Relation 'missing' is not found in App\\User model.", 47], + ["Relation 'missing' is not found in App\\User model.", 48], + ["Relation 'missing' is not found in App\\Team model.", 49], + ["Relation 'accounts:id' is not found in App\\User model.", 62], + ["Relation 'accounts:id' is not found in App\\User model.", 63], + ["Relation 'commentable.user' is not found in App\\Comment model.", 69], + ["Relation 'missing' is not found in App\\Post model.", 78], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-rules.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/UndefinedArgumentOrOptionRuleTest.php b/tests/PHPStan/Rules/UndefinedArgumentOrOptionRuleTest.php new file mode 100644 index 0000000000..fed67364b5 --- /dev/null +++ b/tests/PHPStan/Rules/UndefinedArgumentOrOptionRuleTest.php @@ -0,0 +1,67 @@ + */ +class UndefinedArgumentOrOptionRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new UndefinedArgumentOrOptionRule(self::getContainer()->getByType(ConsoleApplicationResolver::class)); + } + + public function testRule(): void + { + $this->analyse([ + __DIR__ . '/../application/app/Console/Commands/FooCommand.php', + __DIR__ . '/../application/app/Console/Commands/BarCommand.php', + __DIR__ . '/../application/app/Console/Commands/BazCommand.php', + ], [ + ['Command "foo" does not have argument "foobar".', 23], + // Upstream accepts shortcuts, which option() throws for. + ['Command "foo" does not have option "O".', 34], + ['Command "foo" does not have option "v".', 38], + ]); + } + + public function testRuleWithCommandsRegisteredByTheConsoleKernel(): void + { + // Upstream's fixture console kernel is omitted: Hypervel applications have no kernel + // class, and the kernel discovers app/Console/Commands, as withCommands() configures. + $this->analyse([ + __DIR__ . '/../application/app/Console/Commands/RegisteredInKernelCommand.php', + __DIR__ . '/../application/app/Console/Commands/LoadedByKernelCommand.php', + ], [ + ['Command "registered-in-kernel" does not have argument "undefinedArgument".', 28], + ['Command "registered-in-kernel" does not have option "undefinedOption".', 29], + ['Command "loaded-by-kernel" does not have argument "undefinedArgument".', 28], + ['Command "loaded-by-kernel" does not have option "undefinedOption".', 29], + ]); + } + + public function testRuleChecksTheReceiversCommands(): void + { + $this->analyse([ + __DIR__ . '/../Fixtures/Console/LookupCommand.php', + __DIR__ . '/../Fixtures/Console/WithArgumentCommand.php', + __DIR__ . '/../Type/data/console-commands.php', + ], [ + ['Command "lookup:without" does not have argument "argument".', 20], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-rules.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/UnusableSchemaDumpRuleTest.php b/tests/PHPStan/Rules/UnusableSchemaDumpRuleTest.php new file mode 100644 index 0000000000..5eb3582c79 --- /dev/null +++ b/tests/PHPStan/Rules/UnusableSchemaDumpRuleTest.php @@ -0,0 +1,44 @@ + */ +class UnusableSchemaDumpRuleTest extends RuleTestCase +{ + private const string TIP = 'Fix the dump or regenerate it with schema:dump. Until then, the columns it defines are unknown.'; + + protected function getRule(): Rule + { + return new UnusableSchemaDumpRule; + } + + /** @return array> */ + protected function getCollectors(): array + { + return [new UnusableSchemaDumpCollector(self::getContainer()->getByType(ModelPropertyHelper::class))]; + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/unusable-schema-dump.php'], [ + ['Model property types cannot use this schema dump: Unterminated string literal at SQL line 3.', 3, self::TIP], + ['Model property types cannot use this schema dump: PostgreSQL archives must be exported as plain SQL before parsing.', 1, self::TIP], + ]); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/unusable-schema-dumps.neon']; + } +} diff --git a/tests/PHPStan/Rules/UnusedViewsRuleTest.php b/tests/PHPStan/Rules/UnusedViewsRuleTest.php new file mode 100644 index 0000000000..ad7147b937 --- /dev/null +++ b/tests/PHPStan/Rules/UnusedViewsRuleTest.php @@ -0,0 +1,265 @@ + */ +class UnusedViewsRuleTest extends RuleTestCase +{ + /** @var array{string, int, string} */ + private const array UNUSED_VIEW_ERROR = [ + 'No usage of this view was found.', + 0, + 'Views rendered with a dynamic name, or only by code outside the analyzed paths, are not detected.', + ]; + + /** @var list */ + private array $viewDirectories = [ + __DIR__ . '/../application/resources/views/', + __DIR__ . '/../../../src/foundation/src/Exceptions/views', + ]; + + private ?string $workingDirectory = null; + + protected function getRule(): Rule + { + $viewParser = new ViewParser($this->getContainer()->getService('currentPhpVersionSimpleDirectParser')); + $fileHelper = $this->workingDirectory === null ? $this->getFileHelper() : new FileHelper($this->workingDirectory); + $viewFileHelper = new ViewFileHelper($this->viewDirectories, $fileHelper, self::getContainer()->getByType(RuntimeInputs::class)); + + return new UnusedViewsRule(new UsedViewInAnotherViewCollector($viewParser, new BladeScanner, $viewFileHelper, new Filesystem), $viewFileHelper); + } + + /** @return array> */ + protected function getCollectors(): array + { + return [ + new UsedViewFunctionCollector($this->createReflectionProvider()), + new UsedEmailViewCollector, + new UsedEmailAlternativeSyntaxViewCollector, + new UsedEmailSendViewCollector, + new UsedViewMakeCollector, + new UsedViewFacadeMakeCollector, + new UsedRouteFacadeViewCollector, + ]; + } + + public function testRule(): void + { + // `unused.blade.php` and `components/orphan.blade.php` are referenced nowhere. The + // `components/alert.blade.php` view IS used, but only through the `` Blade component tag + // in `index.blade.php` — so it must not be reported (regression guard for component-tag detection). + // `components/card/card.blade.php` is used the same way, through ``. + // The translation rule's `translation-lookups.blade.php` fixture is referenced nowhere either, and + // `blade-directives/index.blade.php` names three views only in escaped, commented or verbatim text. + $viewsPath = resource_path('views') . '/'; + + // Hypervel: also compares the reported files, which upstream's analyse() does not, in the same analysis. + $reports = array_map( + static fn (Error $error): array => [str_replace($viewsPath, '', $error->getFile()), $error->getMessage(), $error->getLine(), $error->getTip()], + $this->gatherAnalyserErrors([__DIR__ . '/data/FooController.php']), + ); + + sort($reports); + + $this->assertSame(array_map(static fn (string $view): array => [$view, ...self::UNUSED_VIEW_ERROR], [ + 'blade-directives/commented.blade.php', + 'blade-directives/escaped.blade.php', + 'blade-directives/verbatim.blade.php', + 'components/orphan.blade.php', + 'translation-lookups.blade.php', + 'unused.blade.php', + ]), $reports); + } + + public function testNamespacedViewFunctionIsNotHypervelView(): void + { + $this->analyse([ + __DIR__ . '/data/FooController.php', + __DIR__ . '/data/NamespacedViewFunction.php', + ], [ + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + ]); + } + + public function testDiscoveredViewDirectories(): void + { + // The booted finder's namespace hints, such as pagination's, are scanned for usages but not reported. + $this->viewDirectories = []; + + $this->analyse([__DIR__ . '/data/FooController.php'], [ + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + ]); + } + + public function testRelativeViewDirectories(): void + { + // The test container's own working directory is PHPStan's package root. + $this->workingDirectory = dirname(__DIR__, 3); + $this->viewDirectories = ['tests/PHPStan/application/resources/views']; + + $this->analyse([__DIR__ . '/data/FooController.php'], [ + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + self::UNUSED_VIEW_ERROR, + ]); + } + + public function testTracksViewDirectories(): void + { + $tracked = []; + $scope = $this->createStubForIntersectionOfInterfaces([Scope::class, DependencyTracker::class]); + $scope->method('trackValueDependency')->willReturnCallback(static function (string $extension, string $key) use (&$tracked): void { + $tracked[] = [$extension, $key]; + }); + + $this->getRule()->processNode(new CollectedDataNode([], false), $scope); + + $this->assertContains([ + RuntimeInputsResultCacheValueExtension::class, + self::getContainer()->getByType(RuntimeInputsResultCacheValueExtension::class)->createKey(linkedDirectories: [ + __DIR__ . '/../application/resources/views' => '*.blade.php', + __DIR__ . '/../../../src/foundation/src/Exceptions/views' => '*.blade.php', + ]), + ], $tracked); + } + + public function testViewsInSymlinkedDirectories(): void + { + $root = ParallelTesting::tempDir('PHPStanUnusedViewsRule'); + $finder = view()->getFinder(); + $paths = $finder->getPaths(); + + (new Filesystem)->deleteDirectory($root); + mkdir($root . '/views', 0777, true); + mkdir($root . '/shared'); + file_put_contents($root . '/shared/card.blade.php', "@include('shared.badge')\n"); + touch($root . '/shared/badge.blade.php'); + touch($root . '/shared/orphan.blade.php'); + + // The finder finds shared.card through the link, which links back to the views it is inside. + symlink($root . '/shared', $root . '/views/shared'); + symlink($root . '/views', $root . '/shared/loop'); + + $this->viewDirectories = [$root . '/views']; + $finder->addLocation($root . '/views'); + + try { + $reports = array_map( + static fn (Error $error): string => str_replace($root . '/views/', '', $error->getFile()), + $this->gatherAnalyserErrors([__DIR__ . '/data/FooController.php']), + ); + + sort($reports); + + $this->assertSame(['shared/card.blade.php', 'shared/orphan.blade.php'], $reports); + } finally { + $finder->setPaths($paths); + $finder->flush(); + (new Filesystem)->deleteDirectory($root); + } + } + + public function testRegisteredAnonymousComponentsAndViewsThatParseOnlyOnceCompiled(): void + { + $root = ParallelTesting::tempDir('PHPStanUnusedViewsRuleComponents'); + $compiler = Blade::getFacadeRoot(); + $finder = view()->getFinder(); + $paths = $finder->getPaths(); + $hints = $finder->getHints(); + $registrations = [$compiler->getAnonymousComponentPaths(), $compiler->getAnonymousComponentNamespaces()]; + + (new Filesystem)->deleteDirectory($root); + mkdir($root . '/views/dashboard', 0777, true); + mkdir($root . '/views/widgets'); + file_put_contents($root . '/views/page.blade.php', "\n\n\n@include('mixed')\n"); + // The native if is only closed once Blade compiles @endif, so the view parses only once compiled. + file_put_contents($root . '/views/mixed.blade.php', "\n@include('included')\n@endif\n"); + touch($root . '/views/included.blade.php'); + touch($root . '/views/dashboard/panel.blade.php'); + touch($root . '/views/widgets/alert.blade.php'); + touch($root . '/views/widgets/orphan.blade.php'); + + $this->viewDirectories = [$root . '/views']; + $finder->addLocation($root . '/views'); + $compiler->anonymousComponentPath($root . '/views/dashboard', 'dashboard'); + $compiler->anonymousComponentNamespace('widgets', 'ui'); + + try { + $reports = array_map( + static fn (Error $error): string => str_replace($root . '/views/', '', $error->getFile()), + $this->gatherAnalyserErrors([__DIR__ . '/data/FooController.php']), + ); + + sort($reports); + + $this->assertSame(['page.blade.php', 'widgets/orphan.blade.php'], $reports); + } finally { + (fn () => [$this->anonymousComponentPaths, $this->anonymousComponentNamespaces] = $registrations)->call($compiler); + (fn () => $this->hints = $hints)->call($finder); + $finder->setPaths($paths); + $finder->flush(); + (new Filesystem)->deleteDirectory($root); + } + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../src/phpstan/extension.neon', + ]; + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/NoUselessValueFunctionCallsRuleTest.php b/tests/PHPStan/Rules/UselessConstructs/NoUselessValueFunctionCallsRuleTest.php new file mode 100644 index 0000000000..5183ee442c --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/NoUselessValueFunctionCallsRuleTest.php @@ -0,0 +1,54 @@ + */ +class NoUselessValueFunctionCallsRuleTest extends RuleTestCase +{ + public function testNoFalsePositives(): void + { + $this->analyse( + [ + __DIR__ . '/data/CorrectValueFunctionCall.php', + __DIR__ . '/../data/NamespacedValueFunction.php', + ], + [], + ); + } + + public function testUselessValueCalls(): void + { + $message = "Calling the helper function 'value()' without a closure as the first argument simply returns the first argument without doing anything"; + + $this->analyse( + [ + __DIR__ . '/data/UselessValueFunctionCall.php', + ], + [ + [$message, 13], + [$message, 18], + [$message, 23], + [$message, 28], + [$message, 33], + [$message, 38], + [$message, 43], + ], + ); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/UselessValueFunctionCall.php', __DIR__ . '/data/UselessValueFunctionCallFixed.php'); + } + + protected function getRule(): Rule + { + return new NoUselessValueFunctionCallsRule($this->createReflectionProvider()); + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/NoUselessWithFunctionCallsRuleTest.php b/tests/PHPStan/Rules/UselessConstructs/NoUselessWithFunctionCallsRuleTest.php new file mode 100644 index 0000000000..dfadc5debf --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/NoUselessWithFunctionCallsRuleTest.php @@ -0,0 +1,47 @@ + */ +class NoUselessWithFunctionCallsRuleTest extends RuleTestCase +{ + public function testNoFalsePositives(): void + { + $this->analyse( + [ + __DIR__ . '/data/CorrectWithFunctionCall.php', + ], + [], + ); + } + + public function testUselessWithCalls(): void + { + $this->analyse( + [ + __DIR__ . '/data/UselessWithFunctionCall.php', + ], + [ + ["Calling the helper function 'with()' with only one argument simply returns the value itself. If you want to chain methods on a construct, use '(new ClassName())->foo()' instead", 11], + ["Calling the helper function 'with()' without a callable as the second argument simply returns the value without doing anything", 16], + ["Calling the helper function 'with()' with only one argument simply returns the value itself. If you want to chain methods on a construct, use '(new ClassName())->foo()' instead", 26], + ], + ); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/UselessWithFunctionCall.php', __DIR__ . '/data/UselessWithFunctionCallFixed.php'); + } + + protected function getRule(): Rule + { + return new NoUselessWithFunctionCallsRule($this->createReflectionProvider()); + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/data/CorrectValueFunctionCall.php b/tests/PHPStan/Rules/UselessConstructs/data/CorrectValueFunctionCall.php new file mode 100644 index 0000000000..d0ed92e33b --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/data/CorrectValueFunctionCall.php @@ -0,0 +1,26 @@ + 'foo'); + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/data/CorrectWithFunctionCall.php b/tests/PHPStan/Rules/UselessConstructs/data/CorrectWithFunctionCall.php new file mode 100644 index 0000000000..8d2a98a14f --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/data/CorrectWithFunctionCall.php @@ -0,0 +1,31 @@ + strtoupper($bar), value: 'foo'); + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCall.php b/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCall.php new file mode 100644 index 0000000000..75842a0cd7 --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCall.php @@ -0,0 +1,50 @@ +count()); + } + + public function undefinedConstantArgument(): string + { + return value('foo', UNDEFINED_CONSTANT); + } + + public function created(): ArrayObject + { + return value(new ArrayObject()); + } + + private function count(): int + { + return 1; + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCallFixed.php b/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCallFixed.php new file mode 100644 index 0000000000..caf3661c56 --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/data/UselessValueFunctionCallFixed.php @@ -0,0 +1,50 @@ +count()); + } + + public function undefinedConstantArgument(): string + { + return value('foo', UNDEFINED_CONSTANT); + } + + public function created(): ArrayObject + { + return new ArrayObject(); + } + + private function count(): int + { + return 1; + } +} diff --git a/tests/PHPStan/Rules/UselessConstructs/data/UselessWithFunctionCall.php b/tests/PHPStan/Rules/UselessConstructs/data/UselessWithFunctionCall.php new file mode 100644 index 0000000000..995f06ced7 --- /dev/null +++ b/tests/PHPStan/Rules/UselessConstructs/data/UselessWithFunctionCall.php @@ -0,0 +1,28 @@ + */ +class NoPipeSeparatedValidationRulesAttributeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoPipeSeparatedValidationRulesAttributeRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/pipe-separated-rule-attributes.php'], [ + [NoPipeSeparatedValidationRulesRuleTest::fixable('required|email'), 12], + [NoPipeSeparatedValidationRulesRuleTest::fixable('required|string'), 16], + [NoPipeSeparatedValidationRulesRuleTest::fixable('max:5|min:1'), 18], + [NoPipeSeparatedValidationRulesRuleTest::fixable('required|string'), 20], + ]); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/pipe-separated-rule-attributes.php', __DIR__ . '/data/pipe-separated-rule-attributes-fixed.php'); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Validation/NoPipeSeparatedValidationRulesReturnRuleTest.php b/tests/PHPStan/Rules/Validation/NoPipeSeparatedValidationRulesReturnRuleTest.php new file mode 100644 index 0000000000..676b25a95a --- /dev/null +++ b/tests/PHPStan/Rules/Validation/NoPipeSeparatedValidationRulesReturnRuleTest.php @@ -0,0 +1,34 @@ + */ +class NoPipeSeparatedValidationRulesReturnRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoPipeSeparatedValidationRulesReturnRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/pipe-separated-rules-methods.php'], [ + [NoPipeSeparatedValidationRulesRuleTest::fixable('sometimes|string'), 16], + [NoPipeSeparatedValidationRulesRuleTest::fixable('required|string|max:255'), 22], + [NoPipeSeparatedValidationRulesRuleTest::unfixable('regex:/^(a|b)$/') . NoPipeSeparatedValidationRulesRuleTest::REGEX_TIP, 24], + [NoPipeSeparatedValidationRulesRuleTest::fixable('required|string'), 44], + [NoPipeSeparatedValidationRulesRuleTest::fixable('required|min:1'), 45], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Validation/NoPipeSeparatedValidationRulesRuleTest.php b/tests/PHPStan/Rules/Validation/NoPipeSeparatedValidationRulesRuleTest.php new file mode 100644 index 0000000000..a14ea3dfeb --- /dev/null +++ b/tests/PHPStan/Rules/Validation/NoPipeSeparatedValidationRulesRuleTest.php @@ -0,0 +1,101 @@ + */ +class NoPipeSeparatedValidationRulesRuleTest extends RuleTestCase +{ + public const string DYNAMIC = 'Validation rules are pipe-separated; list them in an array.'; + + public const string REGEX_TIP = "\n 💡 A regex containing \"|\" must be its own array element."; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoPipeSeparatedValidationRulesRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/pipe-separated-calls.php'], [ + [self::fixable('required|string'), 35], + [self::fixable('required|string'), 36], + [self::fixable('required|email'), 37], + [self::fixable('required|max:255'), 38], + [self::fixable('required|max:255'), 39], + [self::fixable('required|string'), 40], + [self::fixable('required|string'), 41], + [self::fixable('required|string'), 42], + [self::fixable('required|string'), 43], + [self::fixable('required|string'), 44], + [self::fixable('required|string'), 45], + [self::fixable('required|max:500'), 46], + [self::fixable('required|string'), 47], + [self::fixable('required|string'), 50], + [self::DYNAMIC, 51], + [self::DYNAMIC, 52], + [self::DYNAMIC, 53], + [self::unfixable('required|regex:/^(a|b)$/') . self::REGEX_TIP, 54], + [self::unfixable('regex:/^(a|b)$/') . self::REGEX_TIP, 55], + [self::unfixable('required | string'), 56], + [self::unfixable('required|'), 57], + [self::DYNAMIC, 58], + [self::fixable('required|string'), 64], + [self::fixable('nullable|string'), 64], + [self::fixable('nullable|string'), 65], + [self::fixable('required|string'), 66], + [self::fixable('required|string'), 67], + [self::fixable('required|string'), 67], + [self::fixable('required|email'), 68], + [self::fixable('required|string'), 73], + [self::fixable('required|string'), 74], + [self::fixable('required|max:255'), 79], + [self::fixable('required|max:255'), 80], + [self::fixable('required|min:3'), 81], + [self::fixable('required|max:5'), 82], + [self::fixable('required|string'), 87], + [self::fixable('max:5|min:1'), 87], + [self::DYNAMIC, 88], + [self::unfixable('required|regex:/^(a|b)$/') . self::REGEX_TIP, 89], + ]); + } + + public function testCorrectRules(): void + { + // The attribute rule reports #[Rule], so these calls never see it again. + $this->analyse([__DIR__ . '/data/correct-validation-rules.php', __DIR__ . '/data/pipe-separated-rule-attributes.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/pipe-separated-calls.php', __DIR__ . '/data/pipe-separated-calls-fixed.php'); + } + + /** + * Get the message for rules with an unambiguous array replacement. + */ + public static function fixable(string $rules): string + { + $segments = implode(', ', array_map(static fn (string $segment): string => "'{$segment}'", explode('|', $rules))); + + return "Validation rules '{$rules}' are pipe-separated; list them in an array: [{$segments}]."; + } + + /** + * Get the message for rules whose split is ambiguous. + */ + public static function unfixable(string $rules): string + { + return "Validation rules '{$rules}' are pipe-separated; list them in an array."; + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Validation/data/correct-validation-rules.php b/tests/PHPStan/Rules/Validation/data/correct-validation-rules.php new file mode 100644 index 0000000000..570776d25c --- /dev/null +++ b/tests/PHPStan/Rules/Validation/data/correct-validation-rules.php @@ -0,0 +1,82 @@ +> $arguments + * @param list $rules + */ + public function arrays(array $data, Request $request, ValidatorInstance $validator, array $arguments, array $rules, string $prefix, string $pattern): void + { + Validator::make($data, [ + 'name' => ['required', 'string'], + 'code' => ['regex:/^(a|b)$/'], + 'items.*.code' => ['not_regex:/^(a|b)$/'], + // The validator does not split array elements, so this fails at runtime as an unknown rule instead. + 'broken' => ['required', 'string|max:5'], + 'callback' => static fn (string $attribute, mixed $value, callable $fail): null => null, + 'each' => Rule::forEach(static fn (): string => 'required|string'), + 'plain' => 'required', + ]); + + $variable = ['name' => 'required|string']; + Validator::make($data, $variable); + Validator::make(...$arguments); + $request->validate(...)(['name' => 'required|string']); + $validator->hasRule('name', 'required|string'); + + Rule::anyOf([...$rules, ['required', 'string']]); + new DataRule('regex:/^(a|b)$/', ['required', 'string']); + new DataRule($prefix . '|b)$/'); + new DataRule('regex:/^(' . $pattern . '|b)$/'); + } + + public function prompts(FormBuilder $form): void + { + text('Name', validate: ['required', 'max:255']); + text('Name', validate: static fn (string $value): ?string => null); + $form->text('Name', validate: ['name' => ['required', 'max:255']]); + } + + public function unrelated(Unrelated $unrelated, OverridingController $controller, Request $request): void + { + $unrelated->validate(['name' => 'required|string']); + $unrelated->when(true, 'required|string'); + $controller->validate($request, ['name' => 'required|string']); + } +} + +class Unrelated +{ + public function validate(array $rules): void + { + } + + public function when(bool $condition, string $rules): void + { + } +} + +class OverridingController +{ + use ValidatesRequests; + + public function validate(Request $request, array $rules, array $messages = [], array $attributes = []): array + { + return []; + } +} diff --git a/tests/PHPStan/Rules/Validation/data/pipe-separated-calls-fixed.php b/tests/PHPStan/Rules/Validation/data/pipe-separated-calls-fixed.php new file mode 100644 index 0000000000..2ebee83d94 --- /dev/null +++ b/tests/PHPStan/Rules/Validation/data/pipe-separated-calls-fixed.php @@ -0,0 +1,91 @@ + $roles + */ + public function validator(array $data, Factory $factory, Request $request, ?Request $maybeRequest, ValidatorInstance $validator, Translator $translator, array $roles, int $max, bool $flag): void + { + Validator::make($data, ['name' => ['required', 'string'], 'tags' => ['required', 'array']]); + Validator::validate($data, ['name' => ["required", "string"]]); + validator($data, ['email' => ['required', 'email']]); + $factory->make(rules: ['title' => ['required', 'max:255']], data: $data); + $request->validate(['title' => ['required', 'max:255']]); + $request->validateWithBag('post', ['body' => ['required', 'string']]); + $maybeRequest?->validate(['body' => ['required', 'string']]); + $this->validate($request, ['body' => ['required', 'string']]); + $this->validateWithBag('post', $request, ['body' => ['required', 'string']]); + $this->validateWith(['body' => ['required', 'string']], $request); + $validator->setRules(['name' => ['required', 'string']]); + $validator->sometimes('reason', ['required', 'max:500'], static fn (): bool => true); + new ValidatorInstance($translator, $data, ['name' => ['required', 'string']]); + + Validator::make($data, [ + 'name' => self::NAME_RULES, + 'nickname' => $flag ? 'nullable|string' : 'nullable', + 'role' => 'required|in:' . implode(',', $roles), + 'bio' => "nullable|max:{$max}", + 'code' => 'required|regex:/^(a|b)$/', + 'pattern' => 'regex:/^(a|b)$/', + 'spaced' => 'required | string', + 'trailing' => 'required|', + 'alias' => $flag ? 'nullable|string' : 'required|string', + ]); + } + + public function conditions(bool $flag): void + { + Rule::when($flag, ['required', 'string'], ['nullable', 'string']); + Rule::unless(defaultRules: ['nullable', 'string'], rules: ['required', 'string'], condition: $flag); + new ConditionalRules($flag, ['required', 'string']); + Rule::anyOf([['required', 'string'], ['name' => ['required', 'string']], ['required', 'string'], [0 => 'required|string']]); + new AnyOf([['required', 'email']]); + } + + public function wrappers(McpRequest $mcpRequest, ToolRequest $toolRequest): void + { + $mcpRequest->validate(['location' => ['required', 'string']]); + $toolRequest->validate(['city' => ['required', 'string']]); + } + + public function prompts(FormBuilder $form): void + { + text('Name', validate: ['required', 'max:255']); + text('Name', validate: ['name' => ['required', 'max:255'], 'other' => 'required|string']); + $form->text('Name', validate: ['name' => ['required', 'min:3']]); + new TextPrompt('Name', validate: ['required', 'max:5']); + } + + public function data(string $list): void + { + new DataRule(['required', 'string'], [['max:5', 'min:1']]); + new DataRule('required|in:' . $list); + new DataRule('required|regex:/^(a|b)$/'); + } +} diff --git a/tests/PHPStan/Rules/Validation/data/pipe-separated-calls.php b/tests/PHPStan/Rules/Validation/data/pipe-separated-calls.php new file mode 100644 index 0000000000..b98afd36f5 --- /dev/null +++ b/tests/PHPStan/Rules/Validation/data/pipe-separated-calls.php @@ -0,0 +1,91 @@ + $roles + */ + public function validator(array $data, Factory $factory, Request $request, ?Request $maybeRequest, ValidatorInstance $validator, Translator $translator, array $roles, int $max, bool $flag): void + { + Validator::make($data, ['name' => 'required|string', 'tags' => ['required', 'array']]); + Validator::validate($data, ['name' => "required|string"]); + validator($data, ['email' => 'required|email']); + $factory->make(rules: ['title' => 'required|max:255'], data: $data); + $request->validate(['title' => 'required|max:255']); + $request->validateWithBag('post', ['body' => 'required|string']); + $maybeRequest?->validate(['body' => 'required|string']); + $this->validate($request, ['body' => 'required|string']); + $this->validateWithBag('post', $request, ['body' => 'required|string']); + $this->validateWith(['body' => 'required|string'], $request); + $validator->setRules(['name' => 'required|string']); + $validator->sometimes('reason', 'required|max:500', static fn (): bool => true); + new ValidatorInstance($translator, $data, ['name' => 'required|string']); + + Validator::make($data, [ + 'name' => self::NAME_RULES, + 'nickname' => $flag ? 'nullable|string' : 'nullable', + 'role' => 'required|in:' . implode(',', $roles), + 'bio' => "nullable|max:{$max}", + 'code' => 'required|regex:/^(a|b)$/', + 'pattern' => 'regex:/^(a|b)$/', + 'spaced' => 'required | string', + 'trailing' => 'required|', + 'alias' => $flag ? 'nullable|string' : 'required|string', + ]); + } + + public function conditions(bool $flag): void + { + Rule::when($flag, 'required|string', 'nullable|string'); + Rule::unless(defaultRules: 'nullable|string', rules: ['required', 'string'], condition: $flag); + new ConditionalRules($flag, 'required|string'); + Rule::anyOf(['required|string', ['name' => 'required|string'], ['required', 'string'], [0 => 'required|string']]); + new AnyOf(['required|email']); + } + + public function wrappers(McpRequest $mcpRequest, ToolRequest $toolRequest): void + { + $mcpRequest->validate(['location' => 'required|string']); + $toolRequest->validate(['city' => 'required|string']); + } + + public function prompts(FormBuilder $form): void + { + text('Name', validate: 'required|max:255'); + text('Name', validate: ['name' => 'required|max:255', 'other' => 'required|string']); + $form->text('Name', validate: ['name' => 'required|min:3']); + new TextPrompt('Name', validate: 'required|max:5'); + } + + public function data(string $list): void + { + new DataRule('required|string', ['max:5|min:1']); + new DataRule('required|in:' . $list); + new DataRule('required|regex:/^(a|b)$/'); + } +} diff --git a/tests/PHPStan/Rules/Validation/data/pipe-separated-rule-attributes-fixed.php b/tests/PHPStan/Rules/Validation/data/pipe-separated-rule-attributes-fixed.php new file mode 100644 index 0000000000..52307eec96 --- /dev/null +++ b/tests/PHPStan/Rules/Validation/data/pipe-separated-rule-attributes-fixed.php @@ -0,0 +1,28 @@ +isMethod('put')) { + return ['title' => 'sometimes|string']; + } + + $nested = static fn (): array => ['ignored' => 'required|string']; + + return [ + 'title' => 'required|string|max:255', + 'tags' => ['array', 'string|max:5'], + 'code' => 'regex:/^(a|b)$/', + ]; + } + + public function messages(): array + { + return ['title.required' => 'Use letters|numbers.']; + } +} + +class PostData extends Data +{ + public function __construct( + public string $title, + ) { + } + + public static function rules(ValidationContext $context): array + { + return [ + 'title' => 'required|string', + 'tags' => ['array', 'required|min:1'], + 'code' => 'regex:/^(a|b)$/', + ]; + } +} + +class NotARequest +{ + public function rules(): array + { + return ['title' => 'required|string']; + } +} diff --git a/tests/PHPStan/Rules/data/CorrectCollectionCalls.php b/tests/PHPStan/Rules/data/CorrectCollectionCalls.php new file mode 100644 index 0000000000..15691a1434 --- /dev/null +++ b/tests/PHPStan/Rules/data/CorrectCollectionCalls.php @@ -0,0 +1,153 @@ + 'Daan', 'email' => 'test@test.dev']]; + + return User::hydrate($users)->first(); + } + + /** @return Collection */ + public function pluckQuery(): Collection + { + return User::query()->pluck('id'); + } + + /** @return Collection */ + public function pluckComputed(): Collection + { + return User::all()->pluck('allCapsName'); + } + + /** @return Collection */ + public function pluckRelation(): Collection + { + return User::with(['accounts'])->get()->pluck('accounts'); + } + + public function firstRelation(): ?Account + { + return User::firstOrFail()->accounts()->first(); + } + + /** @phpstan-return mixed */ + public function maxQuery() + { + return DB::table('users')->max('id'); + } + + /** @phpstan-return mixed */ + public function collectionCalls() + { + return collect([1, 2, 3])->flip()->reverse()->sum(); + } + + /** + * Can't analyze the closure as a parameter to contains, so should not throw any error. + * + * @return bool + */ + public function testContainsClosure(): bool + { + return User::where('id', '>', 1)->get()->contains(function (User $user): bool { + return $user->id === 2; + }); + } + + /** + * Can't analyze the arrow function as a parameter to contains, so should not throw any error. + * + * @return bool + */ + public function testContainsArrowFunction(): bool + { + return User::where('id', '>', 1)->get()->contains(fn (User $user): bool => $user->id === 2); + } + + /** + * Can't analyze the closure as a parameter to first, so should not throw any error. + * + * @return User|null + */ + public function testFirstClosure(): ?User + { + return User::where('id', '>', 1)->get()->first(function (User $user): bool { + return $user->id === 2; + }); + } + + /** + * Can't analyze the arrow function as a parameter to first, so should not throw any error. + * + * @return User|null + */ + public function testFirstArrowFunction(): ?User + { + return User::where('id', '>', 1)->get()->first(fn (User $user): bool => $user->id === 2); + } + + /** @phpstan-return mixed */ + public function testAggregateNoArgs() + { + return User::query() + ->select([DB::raw('COUNT(*) as temp')]) + ->pluck('temp') + ->sum(); + } + + /** @phpstan-return mixed */ + public function testRelationAggregate(User $user) + { + return $user->group() + ->withCount(['accounts' => function ($query) { + $query->where('id', '<>', 1); + }]) + ->pluck('id') + ->avg(); + } +} + +class Foo extends Model +{ + /** + * @param \Hypervel\Database\Query\Builder $query + * @return FooBuilder + */ + public function newEloquentBuilder($query): FooBuilder + { + return new FooBuilder($query); + } +} + +/** + * @extends Builder + */ +class FooBuilder extends Builder +{ + /** + * @return mixed + */ + public function returnMixed() + { + /** @var mixed */ + return $this; + } +} diff --git a/tests/PHPStan/Rules/data/CorrectModelInstantiation.php b/tests/PHPStan/Rules/data/CorrectModelInstantiation.php new file mode 100644 index 0000000000..70f4a7eefe --- /dev/null +++ b/tests/PHPStan/Rules/data/CorrectModelInstantiation.php @@ -0,0 +1,38 @@ +group()->make(); + } + + public function makeFromTrait(): User + { + return $this->makeInTrait(); + } + + protected function makeInTrait(): User + { + return new User; + } +} diff --git a/tests/PHPStan/Rules/data/EnvUsageClass.php b/tests/PHPStan/Rules/data/EnvUsageClass.php new file mode 100644 index 0000000000..24d2dc68ca --- /dev/null +++ b/tests/PHPStan/Rules/data/EnvUsageClass.php @@ -0,0 +1,15 @@ +callEnv(); + } +} diff --git a/tests/PHPStan/Rules/data/EnvUsageClassOverride.php b/tests/PHPStan/Rules/data/EnvUsageClassOverride.php new file mode 100644 index 0000000000..6821824090 --- /dev/null +++ b/tests/PHPStan/Rules/data/EnvUsageClassOverride.php @@ -0,0 +1,20 @@ +callEnv(); + } + + protected function callEnv(): void + { + // + } +} diff --git a/tests/PHPStan/Rules/data/EnvUsageTrait.php b/tests/PHPStan/Rules/data/EnvUsageTrait.php new file mode 100644 index 0000000000..b8f5aac858 --- /dev/null +++ b/tests/PHPStan/Rules/data/EnvUsageTrait.php @@ -0,0 +1,20 @@ +markdown('emails.mailable.markdown'); + } + + public function bar(): self + { + $this->html('unused'); + + return $this->view('emails.mailable.view'); + } + + public function content(): Content + { + $foo = new Content('emails.mailable.content.view',); + $bar = new Content(markdown: 'emails.mailable.content.markdown'); + + $foo + ->text('emails.mailable.content.text') + ->html('emails.mailable.content.html'); + + return $bar; + } +} + +class FooMailMessage extends MailMessage +{ + public function build(): self + { + return $this->markdown('emails.mail-message.markdown'); + } + + public function bar(): self + { + return $this->view('emails.mail-message.view'); + } +} + +function mailFacadeSend(): void +{ + Mail::send(data: [], view: 'emails.mail-send.send-static'); + + Mail::to('mail@example.com') + ->send(new FooMailable()); +} + +function mailerContractSend(MailerContract $mailer): void +{ + $mailer->send(data: [], view: 'emails.mail-send.mailer'); + $mailer->send('emails.mail-send.send'); +} + +function viewHelper(): View +{ + return view()->make('view-helper-make'); +} + +function viewFactory(Factory $factory): View +{ + return $factory->make('view-factory-make'); +} + +function viewStaticMake(): View +{ + return \Hypervel\Support\Facades\View::make('view-static-make'); +} + +function routeView(): void +{ + Route::view('/welcome', 'route-view'); +} + +function dummyTranslationView() +{ + return view('translations'); +} + +class SomeClass { + function someView(): void + { + view('modals/' . __FUNCTION__); + } +} + +// Hypervel: views passed as reordered named arguments. +function namedArguments(Factory $factory): void +{ + view(data: [], view: 'named-arguments.helper'); + $factory->make(data: [], view: 'named-arguments.factory-make'); + \Hypervel\Support\Facades\View::make(data: [], view: 'named-arguments.facade-make'); + Route::view(view: 'named-arguments.route-view', uri: '/named-arguments'); +} + +class NamedArgumentsMailable extends Mailable +{ + public function build(): self + { + return $this->text(data: [], textView: 'named-arguments.mailable-text'); + } +} + +class NamedArgumentsMailMessage extends MailMessage +{ + public function build(): self + { + return $this->view(data: [], view: 'named-arguments.mail-message-view'); + } +} + +// Hypervel: a mailer view array names the HTML and plain-text views, and the other mailer methods +// that take a view count too. The raw key holds text, not a view. +function mailerViews(MailerContract $mailer): void +{ + Mail::send(['emails.mail-send.positional-html', 'emails.mail-send.positional-text']); + $mailer->send(['html' => 'emails.mail-send.keyed-html', 'text' => 'emails.mail-send.keyed-text', 'raw' => 'unused']); + Mail::sendNow('emails.mail-send.send-now'); + Mail::plain('emails.mail-send.plain', [], static function (): void { + }); + Mail::render('emails.mail-send.render'); +} + +class ArrayViewMailMessage extends MailMessage +{ + public function build(): self + { + return $this->view(['emails.mail-message.array-html', 'emails.mail-message.array-text']); + } +} + +// Hypervel: method names are case-insensitive. +function caseInsensitiveMethods(Factory $factory): void +{ + $factory->MAKE('case-insensitive.factory-make'); + \Hypervel\Support\Facades\View::Make('case-insensitive.facade-make'); + Route::VIEW('/case-insensitive', 'case-insensitive.route-view'); + Mail::Send('case-insensitive.mail-send'); +} + +class CaseInsensitiveMailMessage extends MailMessage +{ + public function build(): self + { + return $this->View('case-insensitive.mail-message-view'); + } +} + +// Hypervel: view names are read from the argument's type, so a constant and each name of a union count. +class ConstantViewNames +{ + private const string HELPER = 'constant-names.helper'; + + private const string FACTORY_MAKE = 'constant-names.factory-make'; + + private const string FACADE_MAKE = 'constant-names.facade-make'; + + private const string ROUTE_VIEW = 'constant-names.route-view'; + + private const string CONTENT = 'constant-names.content'; + + private const array MAIL_VIEWS = ['constant-names.mail-html', 'constant-names.mail-text']; + + public function render(Factory $factory, MailerContract $mailer, bool $compact): void + { + view(self::HELPER); + view($compact ? 'constant-names.compact' : 'constant-names.full'); + $factory->make(self::FACTORY_MAKE); + \Hypervel\Support\Facades\View::make(self::FACADE_MAKE); + Route::view('/constant-names', self::ROUTE_VIEW); + $mailer->send(self::MAIL_VIEWS); + new Content(view: self::CONTENT); + } +} + +// Hypervel: the views rendered through Blade directives are read from their arguments. +function bladeDirectives(): View +{ + return view('blade-directives.index'); +} diff --git a/tests/PHPStan/Rules/data/ModelAppends.php b/tests/PHPStan/Rules/data/ModelAppends.php new file mode 100644 index 0000000000..f8f7b7d0b8 --- /dev/null +++ b/tests/PHPStan/Rules/data/ModelAppends.php @@ -0,0 +1,29 @@ + */ + protected array $appends = [ + 'computed_property', + 'non_existent', + 'email', // This is a database column, should not be appended + 'name', // This is a column with an accessor, which serialization reads through it + ]; + + protected function computedProperty(): Attribute + { + return Attribute::get(fn () => 'foo'); + } + + protected function name(): Attribute + { + return Attribute::get(fn ($value) => ucwords($value)); + } +} diff --git a/tests/PHPStan/Rules/data/ModelMake.php b/tests/PHPStan/Rules/data/ModelMake.php new file mode 100644 index 0000000000..19d4ae98bd --- /dev/null +++ b/tests/PHPStan/Rules/data/ModelMake.php @@ -0,0 +1,29 @@ +makeInTrait(); + } +} diff --git a/tests/PHPStan/Rules/data/ModelMakeRelativeName.php b/tests/PHPStan/Rules/data/ModelMakeRelativeName.php new file mode 100644 index 0000000000..abb85c6339 --- /dev/null +++ b/tests/PHPStan/Rules/data/ModelMakeRelativeName.php @@ -0,0 +1,33 @@ +get('messages.bar'); + $translator->choice('messages.bar', 1); + + $translator->get('messages.test'); + $translator->choice('messages.test', 1); + + Lang::get('messages.baz'); + Lang::choice('messages.baz', 1); + + Lang::get('messages.test'); + Lang::choice('messages.test', 1); + + __('foo bar baz'); + __('messages'); + __('messages.nested'); + __('messages.nested.key'); + + Lang::get('sub/lines.greeting'); + Lang::get('sub/lines.farewell'); + + __('vendor::key'); + + __('messages.You\'re beautiful'); + + __('messages.A.Key.With.Dots.'); + + // Hypervel: method names are case-insensitive. + $translator->GET('messages.test'); + Lang::Choice('messages.test', 1); + } +} diff --git a/tests/PHPStan/Rules/data/UnnecessaryCollectionCallsEloquent.php b/tests/PHPStan/Rules/data/UnnecessaryCollectionCallsEloquent.php new file mode 100644 index 0000000000..4c53bb1ce0 --- /dev/null +++ b/tests/PHPStan/Rules/data/UnnecessaryCollectionCallsEloquent.php @@ -0,0 +1,105 @@ + */ + public function pluckId(): Collection + { + return User::all()->pluck('id'); + } + + public function testCallCountWrong(): int + { + return User::where('id', '>', 5)->get()->count(); + } + + /** @return Collection */ + public function testCallGetPluckWrong(): Collection + { + return User::query()->get()->pluck('id'); + } + + public function testCallCountWrongly(): int + { + return User::all()->count(); + } + + public function testCallFirstWrongly(): ?User + { + return User::all()->first(); + } + + /** @return AccountCollection */ + public function testCallRelationTakeWrongly(): AccountCollection + { + return User::firstOrFail()->accounts()->get()->take(2); + } + + public function testDbQueryBuilder(): int + { + return DB::table('users')->get()->count(); + } + + public function testVarWrong(): bool + { + $query = User::query()->limit(3)->where('email', 'foo@bar.com'); + + return $query->get()->isEmpty(); + } + + public function testVarWrongFirst(): ?User + { + return User::where('id', 1)->get()->first(); + } + + public function testContainsWrong(): bool + { + return User::all()->contains(User::firstOrFail()); + } + + public function testPluckCountWrong(): int + { + return User::query()->pluck('id')->count(); + } + + /** @return EloquentCollection */ + public function testCallWhereWrong(): EloquentCollection + { + return User::all()->where('id', '<', 4); + } + + /** @return EloquentCollection */ + public function testCallDiffWrong(): EloquentCollection + { + return User::all()->diff([new User]); + } + + /** + * @return int[] + */ + public function testCallModelKeysWrong(): array + { + return User::all()->modelKeys(); + } + + public function testContainsStrictWrong(): bool + { + return User::query()->get()->containsStrict('id'); + } + + /** @phpstan-return mixed */ + public function testSum() + { + return User::pluck('id')->sum(); + } +} diff --git a/tests/PHPStan/Rules/data/UnnecessaryCollectionCallsQuery.php b/tests/PHPStan/Rules/data/UnnecessaryCollectionCallsQuery.php new file mode 100644 index 0000000000..df796a659f --- /dev/null +++ b/tests/PHPStan/Rules/data/UnnecessaryCollectionCallsQuery.php @@ -0,0 +1,28 @@ +get()->max('id'); + } + + public function queryNotEmpty(): bool + { + return DB::table('users')->pluck('id')->isNotEmpty(); + } + + /** @return Collection */ + public function queryPluck(): Collection + { + return DB::table('users')->get()->pluck('arbitraryName'); + } +} diff --git a/tests/PHPStan/Rules/data/arr-key-existence.php b/tests/PHPStan/Rules/data/arr-key-existence.php new file mode 100644 index 0000000000..ca7bcd9779 --- /dev/null +++ b/tests/PHPStan/Rules/data/arr-key-existence.php @@ -0,0 +1,83 @@ + '123', + ]; + + Arr::pull($array, 'bar'); +} + +class CustomArr extends Arr +{ +} + +/** + * @param array{foo: string, bar?: int, nested: array{deep: bool}, word: string} $shape + * @param array $map + * @param Collection $collection + * @param array{foo: string}|null $nullableShape + * @param 'foo'|'missing' $fooOrMissing + * @param 'missing'|'other' $missingKeys + */ +function test(array $shape, array $map, Collection $collection, ?array $nullableShape, string $fooOrMissing, string $missingKeys, string $anyKey, mixed $mixed): void +{ + Arr::get($shape, 'foo'); + Arr::get($shape, 'bar'); + Arr::get($shape, 'nested.deep'); + Arr::get($shape, null); + Arr::get($shape, $fooOrMissing); + Arr::get($shape, $anyKey); + Arr::get($map, 'anything'); + Arr::get($collection, 'anything'); + Arr::get($mixed, 'anything'); + Arr::get((new User())->toArray(), 'anything'); + Arr::get($shape, 'missing', 'default'); + Arr::pull($shape, 'missing', 'default'); + + Arr::get($shape, 'missing'); + Arr::get($shape, 'nested.missing'); + Arr::get($shape, 'word.0'); + Arr::get($shape, $missingKeys); + Arr::get($nullableShape, 'missing'); + Arr::get(key: 'missing', array: $shape); + Arr::GET($shape, 'missing'); + CustomArr::get($shape, 'missing'); + Arr::pull($shape, 'missing'); +} + +/** @param array{foo: string}|ArrKeyExistenceAccess $shapeOrAccess */ +function literalDotKeysAndArrayAccess(array|ArrKeyExistenceAccess $shapeOrAccess, ArrKeyExistenceAccess $access): void +{ + Arr::get(['a.b' => 1], 'a.b'); + Arr::get($shapeOrAccess, 'missing'); + Arr::get($access, 'missing'); + Arr::get($access, '0'); +} + +class OverridingArr extends Arr +{ + public static function get(mixed $array, string|int|float|null $key, mixed $default = null): mixed + { + return 'overridden'; + } +} + +/** @param array{foo: string} $shape */ +function overriddenGet(array $shape): void +{ + OverridingArr::get($shape, 'missing'); + OverridingArr::pull($shape, 'missing'); +} + +/** @implements \ArrayAccess */ +abstract class ArrKeyExistenceAccess implements \ArrayAccess +{ +} diff --git a/tests/PHPStan/Rules/data/config-collection-rule.php b/tests/PHPStan/Rules/data/config-collection-rule.php new file mode 100644 index 0000000000..5d9f97121b --- /dev/null +++ b/tests/PHPStan/Rules/data/config-collection-rule.php @@ -0,0 +1,32 @@ +collection('test.foo'); + config()->collection('test.foo'); + } + + public function presence(): void + { + // An absent key gives the default, which array() rejects + Config::collection('test.missing'); + Config::collection('test.missing', []); + + // Framework code adds log channels, so an undeclared one is unknown + Config::collection('logging.channels.undeclared'); + } +} diff --git a/tests/PHPStan/Rules/data/config-sibling/env-calls.php b/tests/PHPStan/Rules/data/config-sibling/env-calls.php new file mode 100644 index 0000000000..ec75bf3a3f --- /dev/null +++ b/tests/PHPStan/Rules/data/config-sibling/env-calls.php @@ -0,0 +1,6 @@ +array('test.foo'); + config()->float('auth.defaults.guard'); + } + + public function uncertainValues(): void + { + // An env() value may be a string + Config::string('test.default'); + + // Framework code adds log channels, so an undeclared one is unknown + Config::string('logging.channels.undeclared'); + } + + public function absentKeys(): void + { + Config::boolean('test.missing'); + Config::boolean('test.missing', false); + } + + /** + * @param array{string} $arguments + */ + public function argumentForms(array $arguments): void + { + Config::integer(default: 1, key: 'test.foo'); + + // Unpacked arguments are not resolved + Config::integer(...$arguments); + } + + public function customGetter(LenientRepository $config): void + { + // A custom getter keeps its own semantics + $config->integer('test.foo'); + } +} + +class LenientRepository extends Repository +{ + public function integer(string $key, mixed $default = null): int + { + return (int) $this->get($key, $default); + } +} diff --git a/tests/PHPStan/Rules/data/config/env-calls.php b/tests/PHPStan/Rules/data/config/env-calls.php new file mode 100644 index 0000000000..8d3b366fcb --- /dev/null +++ b/tests/PHPStan/Rules/data/config/env-calls.php @@ -0,0 +1,6 @@ +foo = $foo; + $this->bar = $bar; + } +} + +class HypervelEventWithoutConstructor +{ + use Dispatchable; +} + +class HypervelEventWithReference +{ + use Dispatchable; + + public function __construct(int &$count) + { + } +} diff --git a/tests/PHPStan/Rules/data/event-dispatch.php b/tests/PHPStan/Rules/data/event-dispatch.php new file mode 100644 index 0000000000..e0b526ad44 --- /dev/null +++ b/tests/PHPStan/Rules/data/event-dispatch.php @@ -0,0 +1,25 @@ +foo = $foo; + $this->bar = $bar; + } +} + +class HypervelJobWithoutConstructor +{ + use Dispatchable; +} + +class HypervelJobWithOwnDispatch +{ + use Dispatchable; + + public function __construct(public string $id) + { + } + + public static function dispatch(int $id): PendingDispatch + { + return static::newPendingDispatch(new static((string) $id)); + } +} diff --git a/tests/PHPStan/Rules/data/job-dispatch.php b/tests/PHPStan/Rules/data/job-dispatch.php new file mode 100644 index 0000000000..5ba73bd486 --- /dev/null +++ b/tests/PHPStan/Rules/data/job-dispatch.php @@ -0,0 +1,72 @@ + true, 1, 'foo'); +HypervelJob::dispatchUnless(foo: 'foo', boolean: false); +HypervelJob::dispatchIf(...[true, 'foo', 1]); +HypervelJobWithOwnDispatch::dispatch(1); + +// Hypervel: the method name matches in any case. +HypervelJob::DISPATCH(1, 'foo'); + +// Hypervel: a call through self, static or parent constructs the calling class. A non-final class's +// subclass can declare another constructor, so it is not checked. A final class is checked against its +// own constructor, also when it calls the trait's method through parent from its own dispatch(). +class HypervelParentJob +{ + use \Hypervel\Foundation\Bus\Dispatchable; + + public function __construct(public string $foo) + { + } + + public static function dispatchDefault(): \Hypervel\Foundation\Bus\PendingDispatch + { + return static::dispatch(1); + } +} + +final class HypervelChildJob extends HypervelParentJob +{ + public function __construct(public int $bar) + { + parent::__construct((string) $bar); + } + + public static function dispatch(mixed ...$arguments): \Hypervel\Foundation\Bus\PendingDispatch + { + return parent::dispatch('foo'); + } +} diff --git a/tests/PHPStan/Rules/data/legacy-model-properties.php b/tests/PHPStan/Rules/data/legacy-model-properties.php new file mode 100644 index 0000000000..c0b8b66bee --- /dev/null +++ b/tests/PHPStan/Rules/data/legacy-model-properties.php @@ -0,0 +1,157 @@ +table = 'reports_' . date('Y'); + + parent::__construct($attributes); + } +} + +class ArchivedReport extends Report +{ + // Report's constructor replaces the table this declares. + protected ?string $table = 'archived_reports'; +} + +trait HasIntegerKey +{ + // PHP requires this to match the inherited declaration, so it only restates it. + protected string $keyType = 'int'; +} + +class Device extends Model +{ + use HasIntegerKey; +} + +class NotAModel +{ + protected ?string $table = 'not_a_model'; +} + +class KeylessToken extends Token +{ + // Table's key applies only over the default 'id', so no attribute gives this null key. + protected ?string $primaryKey = null; +} + +#[Guarded(['secret'])] +class Note extends Model +{ + // This overrides the class's own #[Guarded], which #[Unguarded] beside it would not. + protected array $guarded = []; +} + +class Draft extends Model +{ + // The constructor's makeVisible() changes this list. + protected array $hidden = ['body']; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->makeVisible('body'); + } +} diff --git a/tests/PHPStan/Rules/data/model-appends-hypervel.php b/tests/PHPStan/Rules/data/model-appends-hypervel.php new file mode 100644 index 0000000000..26a0be585d --- /dev/null +++ b/tests/PHPStan/Rules/data/model-appends-hypervel.php @@ -0,0 +1,156 @@ + Favorites::class, + 'backed_enum' => BackedEnumeration::class, + ]; + + public function getComputedAttribute(): string + { + return 'computed'; + } + + /** @return Attribute */ + protected function password(): Attribute + { + return Attribute::set(fn (string $value): string => $value); + } +} + +class ConstantAppends extends Model +{ + private const array APPENDS = ['name']; + + protected ?string $table = 'users'; + + protected array $appends = self::APPENDS; +} + +#[Appends('first_name', 'undetermined_cast')] +class VariadicAppends extends Model +{ + protected ?string $table = 'users'; + + protected function casts(): array + { + return ['undetermined_cast' => $this->castClass()]; + } + + protected function firstName(): Attribute + { + return Attribute::get(fn (): string => 'first'); + } + + private function castClass(): string + { + return Favorites::class; + } +} + +abstract class AppendsFullName extends Model +{ + protected ?string $table = 'users'; + + protected array $appends = ['full_name']; +} + +class WithFullName extends AppendsFullName +{ + protected function fullName(): Attribute + { + return Attribute::get(fn (): string => 'full'); + } +} + +class WithoutFullName extends AppendsFullName +{ +} + +#[Appends(['trait_name'])] +trait AppendsTraitName +{ +} + +class UsesTraitAppends extends Model +{ + use AppendsTraitName; + + protected ?string $table = 'users'; +} + +class CustomSerialization extends Model +{ + protected ?string $table = 'users'; + + protected array $appends = ['anything']; + + protected function mutateAttributeForArray(string $key, mixed $value): mixed + { + return $key === 'anything' ? 'value' : parent::mutateAttributeForArray($key, $value); + } +} + +class SharedDeclaration extends Model +{ + protected ?string $table = 'users'; + + protected array $hidden = [], $appends = ['shared_missing']; +} + +class AppendsDateCast extends Model +{ + protected ?string $table = 'users'; + + protected array $appends = ['email_verified_at']; + + protected array $casts = ['email_verified_at' => 'datetime']; +} + +class CustomArraySerialization extends Model +{ + protected ?string $table = 'users'; + + protected array $appends = ['anything']; + + public function attributesToArray(): array + { + return ['anything' => 'value']; + } +} + +class SpreadAppends extends Model +{ + protected const array APPENDS = ['missing_spread']; + + protected ?string $table = 'users'; + + protected array $appends = [ + ...self::APPENDS, + 'missing_item', + ]; +} diff --git a/tests/PHPStan/Rules/data/model-property-columns.php b/tests/PHPStan/Rules/data/model-property-columns.php new file mode 100644 index 0000000000..97cf5cb640 --- /dev/null +++ b/tests/PHPStan/Rules/data/model-property-columns.php @@ -0,0 +1,124 @@ + + */ +class AliasBuilder extends Builder +{ + /** + * Pluck a column by an alias the builder maps. + * + * @return Collection + */ + public function pluck(Expression|string $column, ?string $key = null): Collection + { + return parent::pluck($column, $key); + } +} + +abstract class AbstractSubject extends Model +{ +} + +/** + * @param AliasBuilder $aliases + * @param Builder $unknown + * @param Builder $mixed + */ +function test(User $user, AliasBuilder $aliases, Builder $unknown, Builder $mixed, string $column): void +{ + User::query()->pluck('name'); + User::query()->pluck('nonexistent'); + User::query()->pluck(DB::raw('count(*)')); + User::query()->pluck($column); + User::query()->paginate(15, ['id', 'nonexistent']); + User::query()->paginate(15, ['*', 'users.id']); + User::query()->simplePaginate(columns: ['nonexistent']); + User::query()->cursorPaginate(15, 'nonexistent'); + + User::pluck('nonexistent'); + User::paginate(15, ['nonexistent']); + + $user->accounts()->pluck('nonexistent'); + $user->roles()->paginate(15, ['nonexistent']); + $user->transactions()->simplePaginate(15, ['nonexistent']); + + $aliases->pluck('alias'); + $unknown->pluck('anything'); + + SearchableUser::query()->pluck('name'); + SearchableUser::query()->pluck('nonexistent'); + + $mixed->pluck('name'); + $mixed->pluck('nonexistent'); + + $class = User::class; + $class::pluck('nonexistent'); +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class WhereBuilder extends Builder +{ + /** + * Constrain a column of whichever model the builder queries. + */ + public function active(): static + { + return $this->whereActive(true); + } + + /** + * Declare a where method that the query does not. + */ + public function whereAlias(string $alias): static + { + return $this->where('name', $alias); + } +} + +/** + * @param WhereBuilder $custom + * @param Builder $unknown + * @param null|Builder $optional + */ +function dynamicWheres(User $user, WhereBuilder $custom, Builder $unknown, ?Builder $optional): void +{ + User::query()->whereNameAndEmail('Taylor', 'taylor@example.com'); + User::query()->whereNameOrNonexistent('Taylor', 1); + User::whereNonexistent(1); + $user->whereNonexistent(1); + $user->accounts()->whereNonexistent(1); + $custom->whereNonexistent(1); + $optional?->pluck('nonexistent'); + + $custom->whereAlias('Taylor'); + User::query()->whereActive(); + User::query()->whereGlobalMacro(); + User::query()->whereQueryMacro(); + $user->accounts()->whereIn('id', [1]); + $unknown->whereAnything(1); +} + +function qualifiedColumns(): void +{ + User::query()->paginate(15, ['users.*', 'users.id as user_id']); + User::query()->paginate(15, ['users.nonexistent']); +} diff --git a/tests/PHPStan/Rules/data/model-relation-defaults.php b/tests/PHPStan/Rules/data/model-relation-defaults.php new file mode 100644 index 0000000000..7694be159f --- /dev/null +++ b/tests/PHPStan/Rules/data/model-relation-defaults.php @@ -0,0 +1,74 @@ + */ + public function children(): HasMany + { + return $this->hasMany(User::class); + } +} + +class InvalidChild extends ParentDefaults +{ +} + +class OverriddenDefaults extends ParentDefaults +{ + protected array $with = []; + protected array $withCount = []; +} + +// Hypervel: upstream declares this default in a trait, which PHP rejects, since it differs from Model's $with. +class NestedDefaults extends User +{ + protected array $with = ['accounts:id' => ['transactions', 'missing']]; +} + +class ConstantDefaults extends User +{ + private const RELATIONS = [2 => 'missing']; + protected array $with = self::RELATIONS; +} + +class NonModel +{ + protected array $with = ['missing']; + protected array $withCount = ['missing']; +} + +class UnknownRelatedDefault extends \App\Comment +{ + protected array $with = ['commentable.anything']; +} + +class SharedDeclaration extends User +{ + protected array $with = ['accounts'], $withCount = ['missing']; +} diff --git a/tests/PHPStan/Rules/data/model-scope-methods.php b/tests/PHPStan/Rules/data/model-scope-methods.php new file mode 100644 index 0000000000..988d9d5af4 --- /dev/null +++ b/tests/PHPStan/Rules/data/model-scope-methods.php @@ -0,0 +1,102 @@ + $query */ + public function scopeStatus(Builder $query, string $status): void; +} + +trait FiltersByOwner +{ + /** @param Builder $query */ + public function scopeOwnedBy(Builder $query, int $ownerId): void + { + } +} + +class Post extends Model implements FiltersByStatus +{ + use FiltersByOwner; + + /** @param Builder $query */ + protected function scopeActive(Builder $query): void + { + } + + /** @param Builder $query */ + public function scopeStatus(Builder $query, string $status): void + { + } + + /** @param Builder $query */ + public function scopeRead(Builder $query): void + { + } + + public function read(): bool + { + return true; + } + + /** @return list */ + public function scopes(): array + { + return []; + } + + /** @param list $haystack */ + public function scopeExistsIn(string $scope, array $haystack): bool + { + return true; + } + + /** @return Attribute */ + public function title() + { + return Attribute::get(fn (): string => 'title'); + } + + /** @return null|Attribute */ + public function summary(): ?Attribute + { + return null; + } + + /** @return Attribute|string */ + public function excerpt(): Attribute|string + { + return 'excerpt'; + } + + /** @return Attribute&Countable */ + public function wordCount(): Attribute&Countable + { + throw new LogicException; + } +} + +class Draft extends Post +{ + /** @param Builder $query */ + protected function scopeActive(Builder $query): void + { + } +} + +class Route +{ + public function scopeBindings(): static + { + return $this; + } +} diff --git a/tests/PHPStan/Rules/data/module/bar/config/env-calls.php b/tests/PHPStan/Rules/data/module/bar/config/env-calls.php new file mode 100644 index 0000000000..be3f280620 --- /dev/null +++ b/tests/PHPStan/Rules/data/module/bar/config/env-calls.php @@ -0,0 +1,12 @@ +user() ? true : false; + } + + public function badCheck(Request $request) + { + return Auth::check(); + } + + public function badUser(Request $request) + { + return Auth::user(); + } + + public function badGuest(Request $request) + { + return Auth::guest(); + } + + public function goodWithNoRequest() + { + return Auth::check(); + } +} + +class CustomRequest extends Request +{ + public function foo(): bool + { + return Auth::check(); + } + + public static function staticFoo(): bool + { + return Auth::check(); + } +} + +class MixedCaseMethod +{ + public function check(Request $request): bool + { + return Auth::Check(); + } +} diff --git a/tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php b/tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php new file mode 100644 index 0000000000..217825dc4d --- /dev/null +++ b/tests/PHPStan/Rules/data/no-auth-helper-in-request-scope-rule.php @@ -0,0 +1,92 @@ +user() ? true : false; + } + + public function badCheck(FooRequest $request): bool + { + return auth()->check(); + } + + public function badUser(FooRequest $request) + { + return auth()->user(); + } + + public function badGuest(Request $request): bool + { + return auth()->guest(); + } + + public function goodWithNoRequest(): bool + { + return auth()->check(); + } + + public function goodWithProperty(Request $request): bool + { + return $this->authManager->check(); + } +} + +class CustomRequest extends Request +{ + public function foo(): bool + { + return auth()->check(); + } + + public static function staticFoo(): bool + { + return auth()->check(); + } +} + +class MixedCaseMethod +{ + public function check(Request $request): bool + { + return auth()->Check(); + } +} + +class MixedCaseFunction +{ + public function check(Request $request): bool + { + return Auth()->check(); + } +} + +namespace Hypervel\Tests\PHPStan\Rules\Data\NoAuthHelperInRequestScope\NamespacedHelper; + +use Hypervel\Auth\AuthManager; +use Hypervel\Http\Request; + +function auth(): AuthManager +{ + return app(AuthManager::class); +} + +class NamespacedHelper +{ + public function check(Request $request): bool + { + return auth()->check(); + } +} diff --git a/tests/PHPStan/Rules/data/no-implicit-query-builder-call-custom-builder-fixed.php b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-custom-builder-fixed.php new file mode 100644 index 0000000000..a4126609d1 --- /dev/null +++ b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-custom-builder-fixed.php @@ -0,0 +1,30 @@ + */ +class UserBuilder extends Builder +{ + public function named(string $name): static + { + return $this->where('name', $name); + } +} + +#[UseEloquentBuilder(UserBuilder::class)] +class User extends Model +{ +} + +function customBuilder(User $user): void +{ + User::query()->named('Jane'); + $user->newQuery()->named('Jane'); + User::query()->named('Jane'); + $user->newQuery()->named('Jane'); + User::undefinedMethod(); +} diff --git a/tests/PHPStan/Rules/data/no-implicit-query-builder-call-custom-builder.php b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-custom-builder.php new file mode 100644 index 0000000000..567052cae0 --- /dev/null +++ b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-custom-builder.php @@ -0,0 +1,30 @@ + */ +class UserBuilder extends Builder +{ + public function named(string $name): static + { + return $this->where('name', $name); + } +} + +#[UseEloquentBuilder(UserBuilder::class)] +class User extends Model +{ +} + +function customBuilder(User $user): void +{ + User::named('Jane'); + $user->named('Jane'); + User::query()->named('Jane'); + $user->newQuery()->named('Jane'); + User::undefinedMethod(); +} diff --git a/tests/PHPStan/Rules/data/no-implicit-query-builder-call-fixed.php b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-fixed.php new file mode 100644 index 0000000000..9939682c47 --- /dev/null +++ b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-fixed.php @@ -0,0 +1,172 @@ + $query */ + public function scopeActive(Builder $query): void + { + $query->where('active', true); + } + + /** @param Builder $query */ + protected function scopeRecent(Builder $query): void + { + $query->latest(); + } + + /** @return HasMany */ + public function posts(): HasMany + { + return $this->hasMany(Post::class); + } + + public function inside(): void + { + self::query()->where('id', 1); + static::query()->active(); + $this->newQuery()->recent(); + $this->scopeActive($this->newQuery()); + $this->scopeRecent($this->newQuery()); + } +} + +/** @extends Builder */ +class PostBuilder extends Builder +{ + public function published(): static + { + return $this->where('published', true); + } +} + +class Post extends Model +{ + public function newEloquentBuilder(QueryBuilder $query): PostBuilder + { + return new PostBuilder($query); + } +} + +class CollidingModel extends User +{ + public static function find(int $id): ?self + { + return null; + } + + public function orderBy(string $column): bool + { + return true; + } + + public function active(): bool + { + return true; + } + + protected function latest(): bool + { + return true; + } +} + +class ChildModel extends CollidingModel +{ +} + +function forwarded(User $user, Post $post): void +{ + User::query()->where('id', 1); + User::query()->find(1); + User::query()->create([]); + User::query()->first(); + User::query()->whereIn('id', [1, 2]); + User::query()->active(); + User::query()->recent(); + User::query()->whereId(1); + Post::query()->published(); + $user->newQuery()->where('id', 1); + $user->newQuery()->find(1); + $user->newQuery()->active(); + $user->newQuery()->recent(); + $user->newQuery()->whereId(1); + $post->newQuery()->published(); +} + +function direct(User $user, ChildModel $child): void +{ + User::query()->where('id', 1)->first(); + $user->newQuery()->active(); + User::all(); + User::with('posts'); + User::destroy(1); + $user->save(); + $user->delete(); + $user->increment('id'); + $user->posts()->where('id', 1); + $user->scopeActive($user->newQuery()); + CollidingModel::find(1); + ChildModel::find(1); + $child->orderBy('id'); + $child->active(); + $child->latest(); + Collection::make([]); + $user->undefinedMethod(); + User::undefinedMethod(); +} + +/** @param class-string $class */ +function classNames(string $class, User $user): void +{ + $class::query()->where('id', 1); + $user::query()->active(); + $known = User::class; + $known::query()->find(1); +} + +/** @param User|Builder $receiver */ +function ambiguous(User|Builder $receiver, User|Post $models, mixed $mixed, object $object, string $method): void +{ + $receiver->where('id', 1); + $models->where('id', 1); + $mixed->where('id', 1); + $object->where('id', 1); + User::$method(); +} + +function nullable(?User $user): void +{ + $user?->newQuery()->where('id', 1); +} + +function undefinedDynamicWhere(User $user): void +{ + User::query()->whereNonexistentColumn(1); + $user->newQuery()->whereNonexistentColumn(1); +} + +/** @param list $arguments */ +function callSyntax(User $user, array $arguments): void +{ + User::query()->where(column: 'id', operator: '=', value: 1)->first(); + User::query()->where(...$arguments); + User::where(...); + $user->where(...); + $user->fresh()?->newQuery()->where('id', 1)->first(); + (new User)->newQuery()->where('id', 1); + // Keep the explanation above the query. + User::query()->where( + 'id', + // Keep the column comment. + 1 + )->first(); +} diff --git a/tests/PHPStan/Rules/data/no-implicit-query-builder-call-forwarding-fixed.php b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-forwarding-fixed.php new file mode 100644 index 0000000000..3cc5ac58ce --- /dev/null +++ b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-forwarding-fixed.php @@ -0,0 +1,44 @@ +where('enabled', true) : $result; + } +} + +class QueriedUser extends Model +{ + /** @return Builder */ + public static function query(): Builder + { + return parent::query()->where('enabled', true); + } +} + +class ScopedUser extends Model +{ + /** @return Builder */ + public function newQuery(): Builder + { + return parent::newQuery()->where('enabled', true); + } +} + +function forwarding(EnabledUser $enabled, QueriedUser $queried, ScopedUser $scoped): void +{ + EnabledUser::where('id', 1); + $enabled->where('id', 1); + QueriedUser::where('id', 1); + $queried->newQuery()->where('id', 1); + ScopedUser::query()->where('id', 1); + $scoped->newQuery()->where('id', 1); +} diff --git a/tests/PHPStan/Rules/data/no-implicit-query-builder-call-forwarding.php b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-forwarding.php new file mode 100644 index 0000000000..7d50d1d4ce --- /dev/null +++ b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-forwarding.php @@ -0,0 +1,44 @@ +where('enabled', true) : $result; + } +} + +class QueriedUser extends Model +{ + /** @return Builder */ + public static function query(): Builder + { + return parent::query()->where('enabled', true); + } +} + +class ScopedUser extends Model +{ + /** @return Builder */ + public function newQuery(): Builder + { + return parent::newQuery()->where('enabled', true); + } +} + +function forwarding(EnabledUser $enabled, QueriedUser $queried, ScopedUser $scoped): void +{ + EnabledUser::where('id', 1); + $enabled->where('id', 1); + QueriedUser::where('id', 1); + $queried->where('id', 1); + ScopedUser::where('id', 1); + $scoped->where('id', 1); +} diff --git a/tests/PHPStan/Rules/data/no-implicit-query-builder-call-scopes-fixed.php b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-scopes-fixed.php new file mode 100644 index 0000000000..0878fa5af3 --- /dev/null +++ b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-scopes-fixed.php @@ -0,0 +1,63 @@ + $query */ + #[Scope] + protected function active(Builder $query): void + { + $query->where('active', true); + } + + /** @param Builder $query */ + #[Scope] + public function named(Builder $query, string $name): void + { + $query->where('name', $name); + } + + /** @param Builder $query */ + #[Scope] + private function recent(Builder $query): void + { + $query->latest(); + } + + /** @param Builder $query */ + public function compose(Builder $query): void + { + $this->active($query); + $this->recent($query); + self::active($query); + static::active($query); + $this->named($query, 'Jane'); + } +} + +class ChildUser extends User +{ + /** @param Builder $query */ + public function composeInherited(Builder $query): void + { + $this->active($query); + parent::active($query); + } +} + +/** @param Builder $query */ +function scopes(User $user, ChildUser $child, Builder $query): void +{ + User::query()->active(); + $user->newQuery()->active(); + ChildUser::query()->active(); + $child->newQuery()->active(); + User::query()->active(); + $user->newQuery()->active(); + $user->named($query, 'Jane'); +} diff --git a/tests/PHPStan/Rules/data/no-implicit-query-builder-call-scopes.php b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-scopes.php new file mode 100644 index 0000000000..81d4ebc773 --- /dev/null +++ b/tests/PHPStan/Rules/data/no-implicit-query-builder-call-scopes.php @@ -0,0 +1,63 @@ + $query */ + #[Scope] + protected function active(Builder $query): void + { + $query->where('active', true); + } + + /** @param Builder $query */ + #[Scope] + public function named(Builder $query, string $name): void + { + $query->where('name', $name); + } + + /** @param Builder $query */ + #[Scope] + private function recent(Builder $query): void + { + $query->latest(); + } + + /** @param Builder $query */ + public function compose(Builder $query): void + { + $this->active($query); + $this->recent($query); + self::active($query); + static::active($query); + $this->named($query, 'Jane'); + } +} + +class ChildUser extends User +{ + /** @param Builder $query */ + public function composeInherited(Builder $query): void + { + $this->active($query); + parent::active($query); + } +} + +/** @param Builder $query */ +function scopes(User $user, ChildUser $child, Builder $query): void +{ + User::active(); + $user->active(); + ChildUser::active(); + $child->active(); + User::query()->active(); + $user->newQuery()->active(); + $user->named($query, 'Jane'); +} diff --git a/tests/PHPStan/Rules/data/no-implicit-query-builder-call.php b/tests/PHPStan/Rules/data/no-implicit-query-builder-call.php new file mode 100644 index 0000000000..38dab5b313 --- /dev/null +++ b/tests/PHPStan/Rules/data/no-implicit-query-builder-call.php @@ -0,0 +1,171 @@ + $query */ + public function scopeActive(Builder $query): void + { + $query->where('active', true); + } + + /** @param Builder $query */ + protected function scopeRecent(Builder $query): void + { + $query->latest(); + } + + /** @return HasMany */ + public function posts(): HasMany + { + return $this->hasMany(Post::class); + } + + public function inside(): void + { + self::where('id', 1); + static::active(); + $this->recent(); + $this->scopeActive($this->newQuery()); + $this->scopeRecent($this->newQuery()); + } +} + +/** @extends Builder */ +class PostBuilder extends Builder +{ + public function published(): static + { + return $this->where('published', true); + } +} + +class Post extends Model +{ + public function newEloquentBuilder(QueryBuilder $query): PostBuilder + { + return new PostBuilder($query); + } +} + +class CollidingModel extends User +{ + public static function find(int $id): ?self + { + return null; + } + + public function orderBy(string $column): bool + { + return true; + } + + public function active(): bool + { + return true; + } + + protected function latest(): bool + { + return true; + } +} + +class ChildModel extends CollidingModel +{ +} + +function forwarded(User $user, Post $post): void +{ + User::where('id', 1); + User::find(1); + User::create([]); + User::first(); + User::whereIn('id', [1, 2]); + User::active(); + User::recent(); + User::whereId(1); + Post::published(); + $user->where('id', 1); + $user->find(1); + $user->active(); + $user->recent(); + $user->whereId(1); + $post->published(); +} + +function direct(User $user, ChildModel $child): void +{ + User::query()->where('id', 1)->first(); + $user->newQuery()->active(); + User::all(); + User::with('posts'); + User::destroy(1); + $user->save(); + $user->delete(); + $user->increment('id'); + $user->posts()->where('id', 1); + $user->scopeActive($user->newQuery()); + CollidingModel::find(1); + ChildModel::find(1); + $child->orderBy('id'); + $child->active(); + $child->latest(); + Collection::make([]); + $user->undefinedMethod(); + User::undefinedMethod(); +} + +/** @param class-string $class */ +function classNames(string $class, User $user): void +{ + $class::where('id', 1); + $user::active(); + $known = User::class; + $known::find(1); +} + +/** @param User|Builder $receiver */ +function ambiguous(User|Builder $receiver, User|Post $models, mixed $mixed, object $object, string $method): void +{ + $receiver->where('id', 1); + $models->where('id', 1); + $mixed->where('id', 1); + $object->where('id', 1); + User::$method(); +} + +function nullable(?User $user): void +{ + $user?->where('id', 1); +} + +function undefinedDynamicWhere(User $user): void +{ + User::whereNonexistentColumn(1); + $user->whereNonexistentColumn(1); +} + +/** @param list $arguments */ +function callSyntax(User $user, array $arguments): void +{ + User::where(column: 'id', operator: '=', value: 1)->first(); + User::where(...$arguments); + User::where(...); + $user->where(...); + $user->fresh()?->where('id', 1)->first(); + (new User)->where('id', 1); + // Keep the explanation above the query. + User::where( + 'id', // Keep the column comment. + 1, + )->first(); +} diff --git a/tests/PHPStan/Rules/data/no-model-make-hypervel.php b/tests/PHPStan/Rules/data/no-model-make-hypervel.php new file mode 100644 index 0000000000..235c32fe6a --- /dev/null +++ b/tests/PHPStan/Rules/data/no-model-make-hypervel.php @@ -0,0 +1,191 @@ + $builder->withAttributes(['status' => 'pending'])); + } +} + +trait BootsScope +{ + public static function bootBootsScope(): void + { + } +} + +class TraitBooted extends FixedConnectionModel +{ + use BootsScope; +} + +trait InitializesState +{ + public function initializeInitializesState(): void + { + } +} + +class TraitInitialized extends FixedConnectionModel +{ + use InitializesState; +} + +class AttributedBoot extends FixedConnectionModel +{ + #[Boot] + protected static function registerScopes(): void + { + } +} + +class AttributedInitialize extends FixedConnectionModel +{ + #[Initialize] + protected function prepareState(): void + { + } +} + +#[UseEloquentBuilder(PostBuilder::class)] +class CustomBuilder extends FixedConnectionModel +{ +} + +class PendingScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + } + + public function extend(Builder $builder): void + { + $builder->withAttributes(['status' => 'pending']); + } +} + +#[ScopedBy(PendingScope::class)] +class AttributedScope extends FixedConnectionModel +{ +} + +#[Attribute(Attribute::TARGET_CLASS)] +class ScopedByPending extends ScopedBy +{ + public function __construct() + { + parent::__construct(PendingScope::class); + } +} + +#[ScopedByPending] +class SubclassedAttributeScope extends FixedConnectionModel +{ +} + +class BootListenedModel extends FixedConnectionModel +{ +} + +#[Connection('sqlite')] +class AttributeConnection extends Model +{ +} + +class DefaultConnection extends Model +{ +} + +class ReadConnection extends Model +{ + protected UnitEnum|string|null $connection = 'sqlite::read'; +} + +class ResolvedConnection extends FixedConnectionModel +{ + public function getConnectionName(): ?string + { + return 'sqlite'; + } +} + +class OpenModel extends FixedConnectionModel +{ + public static function build(): static + { + return static::make(); + } +} + +final class FinalModel extends FixedConnectionModel +{ +} + +/** + * @param class-string $openClass + * @param class-string $finalClass + */ +function instantiate(PlainModel $openModel, FinalModel $finalModel, string $openClass, string $finalClass, bool $flag): void +{ + PlainModel::make(); + PlainModel::MAKE(); + AttributeConnection::make(); + $finalModel::make(); + $finalClass::make(); + ($flag ? PlainModel::class : FinalModel::class)::make(); + ($flag ? PlainModel::class : Booted::class)::make(); + $openModel::make(); + $openClass::make(); + DefaultConnection::make(); + ReadConnection::make(); + ResolvedConnection::make(); + OwnMake::make(); + Booted::make(); + TraitBooted::make(); + TraitInitialized::make(); + AttributedBoot::make(); + AttributedInitialize::make(); + CustomBuilder::make(); + AttributedScope::make(); + SubclassedAttributeScope::make(); + ScopedAtBootModel::make(); + BootListenedModel::make(); + User::make(); + Model::make(); +} diff --git a/tests/PHPStan/Rules/data/no-public-model-scope-and-accessor-fixed.php b/tests/PHPStan/Rules/data/no-public-model-scope-and-accessor-fixed.php new file mode 100644 index 0000000000..22c5d79c17 --- /dev/null +++ b/tests/PHPStan/Rules/data/no-public-model-scope-and-accessor-fixed.php @@ -0,0 +1,100 @@ +where('active', true); + } + + public function scopePublic(Builder $query): Builder + { + return $query->where('public', true); + } + + protected function firstName(): Attribute + { + return Attribute::make( + get: fn (string $value) => ucfirst($value), + set: fn (string $value) => strtolower($value), + ); + } + + public function lastName(): Attribute + { + return Attribute::make( + get: fn (string $value) => ucfirst($value), + set: fn (string $value) => strtolower($value), + ); + } + + #[Scope] + protected function withAttribute(Builder $query): Builder + { + return $query->where('with_attribute', true); + } + + #[Scope] + public function publicWithAttribute(Builder $query): Builder + { + return $query->where('public_with_attribute', true); + } + + public function scopeInvalid(): string + { + return 'invalid'; + } + + public function regularMethod(): string + { + return 'regular'; + } + + protected function scopePrivate(Builder $query): Builder + { + return $query->where('private', true); + } + + protected function privateAccessor(): Attribute + { + return Attribute::make( + get: fn (string $value) => $value, + ); + } +} + +class NonModelClass +{ + public function scopeIgnored(Builder $query): Builder + { + return $query; + } + + public function accessorIgnored(): Attribute + { + return Attribute::make( + get: fn (string $value) => $value, + ); + } +} + +class ChildModel extends TestModel +{ + /** + * @param Builder $query + * @return Builder + */ + public function scopePublic(Builder $query): Builder + { + return $query->where('public', true); + } +} diff --git a/tests/PHPStan/Rules/data/no-public-model-scope-and-accessor.php b/tests/PHPStan/Rules/data/no-public-model-scope-and-accessor.php new file mode 100644 index 0000000000..9881a08aaa --- /dev/null +++ b/tests/PHPStan/Rules/data/no-public-model-scope-and-accessor.php @@ -0,0 +1,100 @@ +where('active', true); + } + + public function scopePublic(Builder $query): Builder + { + return $query->where('public', true); + } + + protected function firstName(): Attribute + { + return Attribute::make( + get: fn (string $value) => ucfirst($value), + set: fn (string $value) => strtolower($value), + ); + } + + public function lastName(): Attribute + { + return Attribute::make( + get: fn (string $value) => ucfirst($value), + set: fn (string $value) => strtolower($value), + ); + } + + #[Scope] + protected function withAttribute(Builder $query): Builder + { + return $query->where('with_attribute', true); + } + + #[Scope] + public function publicWithAttribute(Builder $query): Builder + { + return $query->where('public_with_attribute', true); + } + + public function scopeInvalid(): string + { + return 'invalid'; + } + + public function regularMethod(): string + { + return 'regular'; + } + + private function scopePrivate(Builder $query): Builder + { + return $query->where('private', true); + } + + private function privateAccessor(): Attribute + { + return Attribute::make( + get: fn (string $value) => $value, + ); + } +} + +class NonModelClass +{ + public function scopeIgnored(Builder $query): Builder + { + return $query; + } + + public function accessorIgnored(): Attribute + { + return Attribute::make( + get: fn (string $value) => $value, + ); + } +} + +class ChildModel extends TestModel +{ + /** + * @param Builder $query + * @return Builder + */ + public function scopePublic(Builder $query): Builder + { + return $query->where('public', true); + } +} diff --git a/tests/PHPStan/Rules/data/no-unnecessary-collection-call-hypervel.php b/tests/PHPStan/Rules/data/no-unnecessary-collection-call-hypervel.php new file mode 100644 index 0000000000..826adf86de --- /dev/null +++ b/tests/PHPStan/Rules/data/no-unnecessary-collection-call-hypervel.php @@ -0,0 +1,213 @@ + AsStringable::class, + 'email_verified_at' => 'datetime', + ]; + + /** @return Attribute */ + protected function name(): Attribute + { + return Attribute::get(fn (string $value): string => strtoupper($value)); + } + + /** @param Builder $query */ + #[Scope] + protected function whereVerified(Builder $query): void + { + $query->whereNotNull('email_verified_at'); + } +} + +#[ObservedBy(MemberObserver::class)] +class ObservedMember extends Member +{ +} + +class MemberObserver +{ + public function retrieved(ObservedMember $member): void + { + } +} + +class RetrievedListenedMember extends Member +{ +} + +class RetrievedEventMember extends Member +{ + protected array $dispatchesEvents = ['retrieved' => MemberRetrieved::class]; +} + +class MemberRetrieved +{ + public function __construct(public RetrievedEventMember $member) + { + } +} + +class TranslatedMember extends Member +{ + public function getAttributeValue(string $key): mixed + { + return parent::getAttributeValue($key); + } +} + +/** @extends Collection */ +class CountingCollection extends Collection +{ + public function count(): int + { + return parent::count(); + } +} + +#[CollectedBy(CountingCollection::class)] +class CountedMember extends Member +{ +} + +class KeylessMember extends Member +{ + protected ?string $primaryKey = null; +} + +/** @extends Collection */ +class EmptyCollection extends Collection +{ + public function isEmpty(): bool + { + return true; + } +} + +#[CollectedBy(EmptyCollection::class)] +class EmptyMember extends Member +{ +} + +class ClassCountedMember extends Member +{ + protected static string $collectionClass = CountingCollection::class; +} + +class CollectionTraitMember extends Member +{ + /** @use HasCollection> */ + use HasCollection; +} + +class KeyNamedMember extends Member +{ + public function getKeyName(): ?string + { + return $this->getRawOriginal('key_column') ?? 'id'; + } +} + +class OffsetMember extends Member +{ + public function offsetGet($offset): mixed + { + return parent::offsetGet($offset); + } +} + +class MagicMember extends Member +{ + public function __get(string $key): mixed + { + return parent::__get($key); + } +} + +/** @param Builder $query */ +function collectionCalls(Member $member, Builder $query, string $column, int $limit, bool $flag): void +{ + Member::all()->count(); + Member::where('id', '>', 1)->get()->count(); + Member::query()->whereIn('id', [1])->orderBy('id')->get()->first(); + Member::query()->forPage(2)->skip(1)->limit(5)->get()->first(); + Member::query()->limit(0)->offset(3)->get()->isEmpty(); + Member::with([])->whereEmail('a')->join('accounts', 'accounts.user_id', '=', 'users.id')->get()->isNotEmpty(); + Member::query()->latest()->get()->take(2); + Member::query()->get()->pluck('id'); + Member::all()->pluck('email_verified_at'); + Member::query()->limit(3)->get()->pluck('id'); + Member::query()->whereKey(1)->get()->modelKeys(); + Member::pluck('email')->count(); + ($flag ? Member::class : ObservedMember::class)::all()->count(); + CountedMember::all()->first(); + TranslatedMember::all()->isEmpty(); + DB::table('users')->where('id', 1)->get()->first(); + DB::connection('sqlite')->table('users')->orderBy('id')->get()->count(); + DB::table('users')->pluck('email')->isEmpty(); + CollectionTraitMember::all()->count(); + KeyNamedMember::all()->pluck('id'); + OffsetMember::all()->modelKeys(); + + Member::query()->limit(0)->get()->first(); + Member::query()->limit($limit)->get()->first(); + Member::query()->forPage(2, 0)->get()->first(); + Member::query()->limit(5)->get()->count(); + Member::query()->skip(1)->get()->take(2); + Member::query()->get()->take(-1); + Member::query()->get()->take($limit); + Member::query()->get()->first(fn (Member $member): bool => true); + Member::query()->get(['id'])->count(); + Member::all('id')->count(); + Member::query()->select('id')->get()->count(); + Member::query()->distinct()->get()->pluck('email_verified_at'); + Member::query()->when($flag, fn (Builder $query): Builder => $query)->get()->count(); + Member::query()->whereVerified()->get()->count(); + Member::query()->whereGlobalMacro()->get()->count(); + Member::query()->whereQueryMacro()->get()->count(); + Member::query()->get()->pluck('name'); + Member::query()->get()->pluck('email'); + Member::query()->get()->pluck('missing'); + Member::query()->get()->pluck('id', 'email'); + Member::query()->get()->pluck($column); + Member::query()->join('accounts', 'accounts.user_id', '=', 'users.id')->get()->pluck('id'); + Member::query()->join('accounts', 'accounts.user_id', '=', 'users.id')->get()->modelKeys(); + Member::query()->pluck('id')->first(); + ($flag ? Member::class : CountedMember::class)::all()->count(); + ObservedMember::all()->pluck('id'); + RetrievedListenedMember::all()->pluck('id'); + RetrievedEventMember::all()->modelKeys(); + TranslatedMember::all()->pluck('id'); + CountedMember::all()->count(); + KeylessMember::all()->modelKeys(); + EmptyMember::all()->isNotEmpty(); + ClassCountedMember::all()->count(); + KeyNamedMember::all()->modelKeys(); + OffsetMember::all()->pluck('id'); + MagicMember::all()->modelKeys(); + User::query()->get()->count(); + $member->newQuery()->get()->count(); + $query->get()->count(); + DB::table('users')->get()->pluck('email'); + DB::table($column)->get()->count(); + DB::connection($column)->table('users')->get()->count(); +} diff --git a/tests/PHPStan/Rules/data/redis-connection-bound-method.php b/tests/PHPStan/Rules/data/redis-connection-bound-method.php new file mode 100644 index 0000000000..5c2cff8416 --- /dev/null +++ b/tests/PHPStan/Rules/data/redis-connection-bound-method.php @@ -0,0 +1,45 @@ +client(); + $proxy->AUTH('secret'); + $manager->release(); + $proxy->getConnection(); + + $held->client(); + $proxy->get('key'); + $clientProxy->client(); + $forwardingProxy->client(); +} diff --git a/tests/PHPStan/Rules/data/relation-existence-bare-relations.php b/tests/PHPStan/Rules/data/relation-existence-bare-relations.php new file mode 100644 index 0000000000..091292fc35 --- /dev/null +++ b/tests/PHPStan/Rules/data/relation-existence-bare-relations.php @@ -0,0 +1,28 @@ +whereHas('items.category', static function (Builder $query): void { + $query->whereHas('labels', static function (Builder $query): void { + $query->whereKey(1); + }); +}); + +Owner::query()->whereHas('items', static function (Builder $query): void { + $query->whereHas('category'); +}); + +User::query()->whereHas('posts.comments', static function (Builder $query): void { + $query->whereHas('missing'); +}); + +// Hypervel: a method without a declared return type may be a relation of unknown type, which ends the path. +Shelf::query()->with(['labels', 'labels.anything', 'owner.anything'])->whereHas('labels'); +Shelf::query()->with(['labelsOfType', 'labelsOfKind', 'title', 'getKey', 'where']); diff --git a/tests/PHPStan/Rules/data/relation-existence-column-selection.php b/tests/PHPStan/Rules/data/relation-existence-column-selection.php new file mode 100644 index 0000000000..1b527f387d --- /dev/null +++ b/tests/PHPStan/Rules/data/relation-existence-column-selection.php @@ -0,0 +1,56 @@ + $builder + * @param Collection $users + */ +function constrained(User $user, Builder $builder, Collection $users): void +{ + // Laravel keeps a relation name verbatim when it is paired with a callback. + User::with(['accounts:id,user_id' => static function () {}]); + $builder->with(['accounts:id,user_id' => static fn ($query) => $query]); + $builder->with(['accounts.transactions:id' => static function () {}]); + $builder->with(['accounts:id.transactions' => static function () {}]); + $builder->with(['accounts' => ['transactions:id' => static function () {}]]); + $builder->with('accounts:id,user_id', static function () {}); + $builder->with(callback: static function () {}, relations: 'accounts:id,user_id'); + $builder->withOnly(['accounts:id,user_id' => static function () {}]); + $user->load(['accounts:id,user_id' => static function () {}]); + $user->loadMissing(['accounts:id,user_id' => static function () {}]); + $users->load(['accounts:id,user_id' => static function () {}]); + $users->loadMissing(['accounts:id,user_id' => static function () {}]); + $user->accounts()->with(['transactions:id' => static function () {}]); + $builder->withWhereHas('accounts:id,user_id', static function () {}); + $builder->withWhereHas(callback: static function () {}, relation: 'accounts:id,user_id'); + $builder->withCount(['accounts:id' => static function () {}]); +} + +/** + * @param Builder $builder + * @param Collection $users + */ +function parsed(User $user, Builder $builder, Collection $users, ?\Closure $callback): void +{ + User::with('accounts:id,user_id'); + User::with(['accounts:id,user_id', 'group:id']); + $builder->with('accounts:id,user_id', 'group:id'); + $builder->with(['accounts:id' => ['transactions:id']]); + $builder->with(['accounts:id' => ['transactions' => static function () {}]]); + $builder->with(['accounts' => static function () {}, 'group:id']); + $builder->withOnly(['accounts.transactions:id']); + $user->load('accounts:id,user_id', 'group:id'); + $user->loadMissing(['accounts.transactions:id']); + $users->load(['accounts:id,user_id']); + $builder->withWhereHas('accounts:id,user_id'); + $builder->withWhereHas('accounts:id,user_id', null); + $builder->withWhereHas('accounts:id,user_id', $callback); + $builder->withCount('accounts:id'); +} diff --git a/tests/PHPStan/Rules/data/relation-existence-hypervel.php b/tests/PHPStan/Rules/data/relation-existence-hypervel.php new file mode 100644 index 0000000000..461891a28e --- /dev/null +++ b/tests/PHPStan/Rules/data/relation-existence-hypervel.php @@ -0,0 +1,79 @@ + + */ +class ScopedBuilder extends Builder +{ +} + +/** + * @template TRelated of Model + */ +class GenericModel extends Model +{ + /** + * @return HasMany + */ + public function children(): HasMany + { + throw new LogicException; + } +} + +/** + * @param class-string $modelClass + * @param ScopedBuilder $scopedBuilder + */ +function receivers(string $modelClass, ScopedBuilder $scopedBuilder): void +{ + $modelClass::with('missing'); + $scopedBuilder->has('missing'); + Team::query()->whereHas('missing'); +} + +function dynamicRelations(): void +{ + User::with('dynamicAccounts'); + ChildUser::with('dynamicAccounts'); + User::with('dynamicAccounts.anything'); +} + +function columnSelections(): void +{ + User::withWhereHas('accounts:id', static function (): void {}); + User::whereHas('accounts:id'); + User::withWhereRelation('accounts:id', 'active', true); +} + +function morphPaths(): void +{ + Comment::hasMorph('commentable', Post::class); + Comment::hasMorph('commentable.user', Post::class); +} + +/** + * @param GenericModel $model + */ +function genericPaths(GenericModel $model): void +{ + $model->load('children.users'); + $model->load('children.missing'); +} diff --git a/tests/PHPStan/Rules/data/relation-existence-loading.php b/tests/PHPStan/Rules/data/relation-existence-loading.php new file mode 100644 index 0000000000..74da3cd3b1 --- /dev/null +++ b/tests/PHPStan/Rules/data/relation-existence-loading.php @@ -0,0 +1,127 @@ + $users */ +function loading(User $user, Collection $users, string $name): void +{ + $user->load('missing'); + $user->loadMissing('accounts.missing'); + $user->load(['accounts.transactions', 'missing']); + $user->load(['accounts' => ['missing' => static function () {}]]); + $user->load(['missing' => static function () {}, 'accounts']); + $user->load('accounts:id,user_id', 'missing:id'); + $user->loadMissing('accounts', 'missing'); + $user->loadCount('missing as total'); + $user->loadCount('accounts', 'missing'); + $user->loadAggregate('missing AS total', 'id', 'sum'); + $user->loadMax('missing', 'id'); + $user->loadMin('missing', 'id'); + $user->loadSum(['missing as total' => static function () {}], 'id'); + $user->loadAvg('missing', 'id'); + $user->loadExists('missing'); + $users->load('missing'); + $users->loadMissing('accounts.missing'); + $users->loadCount('missing as total'); + User::withCount('missing as total'); + User::withSum('missing', 'id'); + User::withOnly('missing'); + User::orWhereRelation('missing', 'id', 1); + $user->loadCount('accounts.transactions'); + $user->loadCount('accounts as total'); + $user->load('accounts as total'); + + $user->load(['accounts:id' => ['transactions:id']]); + $user->loadMissing(['accounts.transactions:id', 'group:id']); + $user->load(['accounts' => static function () {}, 'group']); + $user->loadCount(['accounts AS total' => static function () {}]); + $user->loadSum('accounts as total', 'id'); + $user->loadExists('accounts'); + $users->load('accounts.transactions'); + $users->loadCount('accounts as total'); + $users->has('not_a_relation'); + $user->load($name); + $user->loadMissing([$name]); + $user->loadCount([$name => static function () {}]); + $user->loadSum($name, 'id'); + User::with(['accounts' => $name]); + User::with('accounts', static function () {}); +} + +function unknownModel(Model $model): void +{ + $model->load('unknown'); +} + +function otherApis(User $user): void +{ + User::withAggregate('missing', 'id', 'max'); + User::withMax('missing', 'id'); + User::withMin('missing', 'id'); + User::withAvg('missing', 'id'); + User::withExists('missing'); + User::withWhereRelation('missing', 'id', 1); + User::whereDoesntHaveRelation('missing', 'id', 1); + User::orWhereDoesntHaveRelation('missing', 'id', 1); + User::hasMorph('missing', [User::class]); + User::orHasMorph('missing', [User::class]); + User::doesntHaveMorph('missing', [User::class]); + User::orDoesntHaveMorph('missing', [User::class]); + User::whereHasMorph('missing', [User::class]); + User::orWhereHasMorph('missing', [User::class]); + User::whereDoesntHaveMorph('missing', [User::class]); + User::orWhereDoesntHaveMorph('missing', [User::class]); + User::whereMorphRelation('missing', [User::class], 'id', 1); + User::orWhereMorphRelation('missing', [User::class], 'id', 1); + User::whereMorphDoesntHaveRelation('missing', [User::class], 'id', 1); + User::orWhereMorphDoesntHaveRelation('missing', [User::class], 'id', 1); + $user->loadSum(column: 'id', relations: 'missing as total'); + $user->LOAD('missing'); + $user->load(['missing' => ['nested']]); + $user->load('save'); + + $user->loadSum(column: 'id', relations: 'accounts AS total'); + User::withSum(column: 'id', relation: 'accounts'); + $user->loadCount('accounts:id'); + $user->load(...); + User::withCount('accounts', static function () {}); +} + +/** @param User|\App\Post $model */ +function ambiguousModel($model): void +{ + $model->load('unknown'); +} + +function unrelatedReceiver(): void +{ + $receiver = new class { + public function load(string $name): void {} + }; + $receiver->load('unknown'); +} + +function nullableModel(?User $user): void +{ + $user?->load('missing'); + $user?->load('accounts'); +} + +function unknownRelatedModel(\App\Comment $comment): void +{ + $comment->load('commentable.anything'); + $comment->load(['commentable.anything', 'missing']); +} + +/** @param array $names */ +function dynamicList(User $user, array $names): void +{ + $user->load($names); + $user->load(...$names); +} diff --git a/tests/PHPStan/Rules/data/relation-existence-rule.php b/tests/PHPStan/Rules/data/relation-existence-rule.php new file mode 100644 index 0000000000..68a1b3b6b3 --- /dev/null +++ b/tests/PHPStan/Rules/data/relation-existence-rule.php @@ -0,0 +1,64 @@ +has('foo'); +\App\User::query()->orHas('foo'); +\App\User::query()->doesntHave('foo'); +\App\User::query()->orDoesntHave('foo'); +\App\User::query()->whereHas('foo'); +\App\User::query()->withWhereHas('foo'); +\App\User::query()->orWhereHas('foo'); +\App\User::query()->whereDoesntHave('foo'); +\App\User::query()->orWhereDoesntHave('foo'); + +\App\User::first()->group()->has('foo'); +\App\User::first()->group()->orHas('foo'); +\App\User::first()->group()->doesntHave('foo'); +\App\User::first()->group()->orDoesntHave('foo'); +\App\User::first()->group()->whereHas('foo'); +\App\User::first()->group()->withWhereHas('foo'); +\App\User::first()->group()->orWhereHas('foo'); +\App\User::first()->group()->whereDoesntHave('foo'); +\App\User::first()->group()->orWhereDoesntHave('foo'); + +\App\User::query()->has('accounts.foo'); +\App\User::query()->orHas('accounts.foo'); +\App\User::query()->doesntHave('accounts.foo'); +\App\User::query()->orDoesntHave('accounts.foo'); +\App\User::query()->whereHas('accounts.foo'); +\App\User::query()->withWhereHas('accounts.foo'); +\App\User::query()->orWhereHas('accounts.foo'); +\App\User::query()->whereDoesntHave('accounts.foo'); +\App\User::query()->orWhereDoesntHave('accounts.foo'); + +\App\Post::query()->has('users.transactions.foo'); +\App\Post::query()->orHas('users.transactions.foo'); +\App\Post::query()->doesntHave('users.transactions.foo'); +\App\Post::query()->orDoesntHave('users.transactions.foo'); +\App\Post::query()->whereHas('users.transactions.foo'); +\App\Post::query()->withWhereHas('users.transactions.foo'); +\App\Post::query()->orWhereHas('users.transactions.foo'); +\App\Post::query()->whereDoesntHave('users.transactions.foo'); +\App\Post::query()->orWhereDoesntHave('users.transactions.foo'); + +\App\User::with('foo'); +\App\User::query()->with('foo'); +\App\User::with(['foo', 'accounts']); +\App\User::query()->with(['foo', 'accounts']); +\App\User::query()->with(['foo', 'accounts.foo']); +\App\User::query()->with(['foo' => static function () { +}, 'accounts']); + +\App\User::with('foo:id,name'); +\App\User::query()->with('foo:id,name'); +\App\User::with(['foo:id', 'accounts']); +\App\User::query()->with(['foo:id', 'accounts']); + +/** @var array{foo:string}|array{0: 'bar'} $a */ +\App\User::with($a); + +\App\User::query()->whereRelation('foo', 'id'); +\App\User::first()->group()->whereRelation('foo', 'id'); +\App\User::query()->whereRelation('accounts.foo', 'id'); +\App\Post::query()->whereRelation('users.transactions.foo', 'id'); diff --git a/tests/PHPStan/Rules/data/unnecessary-enumerable-toArray-calls.php b/tests/PHPStan/Rules/data/unnecessary-enumerable-toArray-calls.php new file mode 100644 index 0000000000..1419364cb3 --- /dev/null +++ b/tests/PHPStan/Rules/data/unnecessary-enumerable-toArray-calls.php @@ -0,0 +1,69 @@ + $ints + * @param PlainCollection $plain + * @param EloquentCollection $models + * @param LazyCollection $innerUnion + * @param EloquentCollection|Collection $outerUnion + * @param ToArrayOverridingCollection $overriding + * @param Enumerable $enumerable + */ +function test( + mixed $unknown, + Collection $ints, + PlainCollection $plain, + EloquentCollection $models, + LazyCollection $innerUnion, + Enumerable $outerUnion, + ToArrayOverridingCollection $overriding, + Enumerable $enumerable, +): void { + // should be reported + $ints->toArray(); + $plain->toArray(); + // Hypervel: method names are case-insensitive. + $ints->ToArray(); + + // should not be reported + $unknown->toArray(); + $models->toArray(); + $innerUnion->toArray(); + $outerUnion->toArray(); + $overriding->toArray(); + $enumerable->toArray(); +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class PlainCollection extends Collection +{ +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class ToArrayOverridingCollection extends Collection +{ + public function toArray(): array + { + return ['count' => $this->count()]; + } +} diff --git a/tests/PHPStan/Rules/data/unusable-schema-dump.php b/tests/PHPStan/Rules/data/unusable-schema-dump.php new file mode 100644 index 0000000000..6d935a5a4a --- /dev/null +++ b/tests/PHPStan/Rules/data/unusable-schema-dump.php @@ -0,0 +1,49 @@ +label; + echo $mySql->id; + + // A declared property does not consult the schema. + echo $mariaDb->exists ? 'yes' : 'no'; + + echo $postgres?->label; + + // The default connection's facts come from usable migrations. + echo $sqlite->name; +} diff --git a/tests/PHPStan/Rules/data/unusable-schema-dumps.neon b/tests/PHPStan/Rules/data/unusable-schema-dumps.neon new file mode 100644 index 0000000000..fddd8cd808 --- /dev/null +++ b/tests/PHPStan/Rules/data/unusable-schema-dumps.neon @@ -0,0 +1,5 @@ +includes: + - ../phpstan-rules.neon +parameters: + squashedMigrationsPath: + - %rootDir%/../../../tests/PHPStan/Rules/data/unusable-schema-dumps diff --git a/tests/PHPStan/Rules/data/unusable-schema-dumps/mariadb-schema.sql b/tests/PHPStan/Rules/data/unusable-schema-dumps/mariadb-schema.sql new file mode 100644 index 0000000000..f8a9a27958 --- /dev/null +++ b/tests/PHPStan/Rules/data/unusable-schema-dumps/mariadb-schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE `accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `label` varchar(255) DEFAULT 'unterminated, + PRIMARY KEY (`id`) +); diff --git a/tests/PHPStan/Rules/data/unusable-schema-dumps/mysql-schema.sql b/tests/PHPStan/Rules/data/unusable-schema-dumps/mysql-schema.sql new file mode 100644 index 0000000000..f8a9a27958 --- /dev/null +++ b/tests/PHPStan/Rules/data/unusable-schema-dumps/mysql-schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE `accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `label` varchar(255) DEFAULT 'unterminated, + PRIMARY KEY (`id`) +); diff --git a/tests/PHPStan/Rules/data/unusable-schema-dumps/pgsql-schema.dump b/tests/PHPStan/Rules/data/unusable-schema-dumps/pgsql-schema.dump new file mode 100644 index 0000000000..5c5c302e18 Binary files /dev/null and b/tests/PHPStan/Rules/data/unusable-schema-dumps/pgsql-schema.dump differ diff --git a/tests/PHPStan/Rules/phpstan-rules.neon b/tests/PHPStan/Rules/phpstan-rules.neon new file mode 100644 index 0000000000..b5afd4c37c --- /dev/null +++ b/tests/PHPStan/Rules/phpstan-rules.neon @@ -0,0 +1,2 @@ +includes: + - ../../../src/phpstan/extension.neon diff --git a/tests/PHPStan/RuntimeInputsDependencyTest.php b/tests/PHPStan/RuntimeInputsDependencyTest.php new file mode 100644 index 0000000000..b6c1fac8da --- /dev/null +++ b/tests/PHPStan/RuntimeInputsDependencyTest.php @@ -0,0 +1,417 @@ +copyApplication(); + + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + $firstWarm = $this->analyse($applicationPath, parameters: self::PARAMETERS); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $firstWarm); + + $secondWarm = $this->analyse($applicationPath, parameters: self::PARAMETERS); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $secondWarm); + $this->assertReanalyzed(0, $secondWarm); + + // Asserted last: PHPStan's class values can differ between the run that first reflected a + // built-in ancestor's stub and later runs reading it from its cache. + $this->assertReanalyzed(0, $firstWarm); + } + + public function testBootInputEditsReanalyzeTheirConsumers(): void + { + $applicationPath = $this->copyApplication(); + $base = self::BOOT_DIAGNOSTICS; + + $this->assertDiagnostics($base, $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + // A macro registered by a provider. + $this->edit($applicationPath . '/app/Providers/FixtureServiceProvider.php', 'fn (): int => 1', "fn (): string => ''"); + $this->assertDiagnostics([...$base, 'Usage.php:18 return.type'], $this->analyse($applicationPath, parameters: self::PARAMETERS)); + $this->edit($applicationPath . '/app/Providers/FixtureServiceProvider.php', "fn (): string => ''", 'fn (): int => 1'); + $this->assertDiagnostics($base, $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + // A mixin class only the provider references. + $this->edit($applicationPath . '/app/StrMixin.php', 'static fn (): int => 1', "static fn (): string => ''"); + $this->assertDiagnostics([...$base, 'Usage.php:23 return.type'], $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + // The body of a registration helper the provider calls. + $this->edit($applicationPath . '/app/Registrar.php', "=== 'french'", "!== 'french'"); + $this->assertDiagnostics( + ['Usage.php:23 return.type', 'Usage.php:33 staticMethod.notFound', 'Usage.php:38 return.type'], + $this->analyse($applicationPath, parameters: self::PARAMETERS), + ); + + // A facade root that lacked the method, which loads only when analysis resolves it. + $this->edit($applicationPath . '/app/FacadeRoot.php', "{\n}", "{\n public function shout(): string\n {\n return '';\n }\n}"); + $this->assertDiagnostics(['Usage.php:23 return.type', 'Usage.php:38 return.type'], $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + // The body of the manager's getDefaultDriver(), which loads only when analysis resolves the manager. + $this->edit($applicationPath . '/app/Exports/ExportManager.php', "return 'csv';", "return 'json';"); + $this->assertDiagnostics(['Usage.php:23 return.type'], $this->analyse($applicationPath, parameters: self::PARAMETERS)); + $this->edit($applicationPath . '/app/Exports/ExportManager.php', "return 'json';", "return 'csv';"); + $this->assertDiagnostics(['Usage.php:23 return.type', 'Usage.php:38 return.type'], $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + // An interface the bound manager subclass's driver creator takes its return type from. + $this->edit($applicationPath . '/app/Exports/CreatesReportDrivers.php', '@return JsonDriver', '@return CsvDriver'); + $this->assertDiagnostics( + ['BoundReportManager.php:14 return.type', 'ReportUsage.php:13 return.type', 'Usage.php:23 return.type', 'Usage.php:38 return.type'], + $this->analyse($applicationPath, parameters: self::PARAMETERS), + ); + $this->edit($applicationPath . '/app/Exports/CreatesReportDrivers.php', '@return CsvDriver', '@return JsonDriver'); + $this->assertDiagnostics(['Usage.php:23 return.type', 'Usage.php:38 return.type'], $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + // The method of a driver the manager forwards to, typed from the manager's declaration. + $this->edit($applicationPath . '/app/Exports/CsvDriver.php', "export(): string\n {\n return '';", "export(): int\n {\n return 0;"); + $this->assertDiagnostics(['Usage.php:23 return.type'], $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + // The morph map, which a collection's loadMorph() reads. + $this->edit($applicationPath . '/app/Providers/FixtureServiceProvider.php', "'post' => Post::class", "'post' => \\RuntimeInputs\\Models\\Video::class"); + $this->assertDiagnostics(['Usage.php:23 return.type', 'Usage.php:46 argument.type'], $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + // A route file and a provider binding, which decide the worker's lifetimes. + $this->edit($applicationPath . '/routes/web.php', "'ok');", "'ok');\nRoute::get('/counter', [\\RuntimeInputs\\CounterController::class, 'index']);"); + $this->assertDiagnostics( + ['CounterController.php:13 hypervel.workerSharedState', 'Usage.php:23 return.type', 'Usage.php:46 argument.type'], + $this->analyse($applicationPath, parameters: self::PARAMETERS), + ); + $this->edit($applicationPath . '/app/Providers/FixtureServiceProvider.php', 'Registrar::register($this->app);', "Registrar::register(\$this->app);\n \$this->app->scoped(\\RuntimeInputs\\CounterController::class);"); + $this->assertDiagnostics(['Usage.php:23 return.type', 'Usage.php:46 argument.type'], $this->analyse($applicationPath, parameters: self::PARAMETERS)); + } + + public function testDynamicRelationRegistrationsReanalyzeTheirConsumers(): void + { + $applicationPath = $this->copyApplication(); + file_put_contents($applicationPath . '/app/RelationUsage.php', <<<'PHP' + replies(); + } + } + PHP); + + $this->edit( + $applicationPath . '/app/Providers/FixtureServiceProvider.php', + "Relation::morphMap(['post' => Post::class]);", + "Relation::morphMap(['post' => Post::class]);\n Post::resolveRelationUsing('replies', static fn (Post \$post): \\Hypervel\\Database\\Eloquent\\Relations\\HasMany => \$post->hasMany(\\RuntimeInputs\\Models\\Comment::class));", + ); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + // The call resolves to the registered relation alone, so only the registration's tracking notices the change. + $this->edit( + $applicationPath . '/app/Providers/FixtureServiceProvider.php', + '\Hypervel\Database\Eloquent\Relations\HasMany => $post->hasMany(', + '\Hypervel\Database\Eloquent\Relations\BelongsTo => $post->belongsTo(', + ); + $this->assertDiagnostics([...self::BOOT_DIAGNOSTICS, 'RelationUsage.php:14 return.type'], $this->analyse($applicationPath, parameters: self::PARAMETERS)); + } + + public function testBootInputEditsSkipLookupsOnClassesWithoutMacros(): void + { + // PHPStan gives a file the dependencies recorded on the declarations of classes it depends on, as far as its + // process has recorded them when the file finishes. One process keeps that order the same between runs, and + // the first edit's reanalysis drops what the cold run gave files that never read those facts (Comment.php, + // through Model), so the count is taken on the second edit. + $applicationPath = $this->copyApplication(); + $parameters = "typeAliases:\n RenderView: 'Closure(view-string): void'\nparallel:\n maximumNumberOfProcesses: 1"; + + $this->analyse($applicationPath, parameters: $parameters); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: $parameters)); + + $this->edit($applicationPath . '/app/Providers/FixtureServiceProvider.php', 'fn (): int => 1', 'fn (): int => 2'); + $this->analyse($applicationPath, parameters: $parameters); + $this->edit($applicationPath . '/app/Providers/FixtureServiceProvider.php', 'fn (): int => 2', 'fn (): int => 3'); + $process = $this->analyse($applicationPath, parameters: $parameters); + $this->assertSame(1, preg_match('/Result cache restored\. (\d+) files? will be reanalysed\./', $process->getErrorOutput(), $matches)); + + // The lookup reaches the macro extension, but a class that is not Macroable can gain no macro from the boot. + file_put_contents($applicationPath . '/app/PlainUsage.php', <<<'PHP' + assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: $parameters)); + + $this->edit($applicationPath . '/app/Providers/FixtureServiceProvider.php', 'fn (): int => 3', 'fn (): int => 4'); + $this->assertReanalyzed((int) $matches[1], $this->analyse($applicationPath, parameters: $parameters)); + } + + public function testEnvironmentChangesReanalyzeTheirConsumers(): void + { + $applicationPath = $this->copyApplication(); + file_put_contents($applicationPath . '/.env', "FIXTURE_GREETER=\${DEPLOY_GREETER}\n"); + $unset = ['FIXTURE_GREETER' => false, 'DEPLOY_GREETER' => false]; + + $this->assertContains('Usage.php:28 method.notFound', $this->diagnostics($this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $unset), 1)); + + // A variable only the process sets, which the configuration reads. + $this->assertNotContains( + 'Usage.php:28 method.notFound', + $this->diagnostics($this->analyse($applicationPath, parameters: self::PARAMETERS, environment: ['FIXTURE_GREETER' => 'french', 'DEPLOY_GREETER' => false]), 1), + ); + $this->assertContains('Usage.php:28 method.notFound', $this->diagnostics($this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $unset), 1)); + + // A variable the environment file interpolates. + $interpolated = ['FIXTURE_GREETER' => false, 'DEPLOY_GREETER' => 'french']; + $this->assertNotContains('Usage.php:28 method.notFound', $this->diagnostics($this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $interpolated), 1)); + + $process = $this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $interpolated); + $this->assertNotContains('Usage.php:28 method.notFound', $this->diagnostics($process, 1)); + $this->assertReanalyzed(0, $process); + + // Reading every variable would make results depend on unrelated ones, such as a session's. + $this->assertStringNotContainsString("env-pattern\t*", (string) file_get_contents($applicationPath . '/cache/resultCache.php')); + + // A computed name reads every variable, compared by digest only. + $this->edit($applicationPath . '/config/fixture.php', "env('FIXTURE_GREETER'", "env(strtoupper('fixture_greeter')"); + $this->assertNotContains('Usage.php:28 method.notFound', $this->diagnostics($this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $interpolated), 1)); + $this->assertStringContainsString("env-pattern\t*", (string) file_get_contents($applicationPath . '/cache/resultCache.php')); + + $process = $this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $interpolated); + $this->assertNotContains('Usage.php:28 method.notFound', $this->diagnostics($process, 1)); + $this->assertReanalyzed(0, $process); + + $unrelatedValue = 'runtime-inputs-unrelated-value'; + $process = $this->analyse($applicationPath, parameters: self::PARAMETERS, environment: [...$interpolated, 'RUNTIME_INPUTS_UNRELATED' => $unrelatedValue]); + $this->assertNotContains('Usage.php:28 method.notFound', $this->diagnostics($process, 1)); + $this->assertMatchesRegularExpression('/Result cache restored\. [1-9]\d* files? will be reanalysed\./', $process->getErrorOutput()); + $this->assertStringNotContainsString($unrelatedValue, (string) file_get_contents($applicationPath . '/cache/resultCache.php')); + + // The environment selects which application boots. + $process = $this->analyse($applicationPath, parameters: self::PARAMETERS, environment: [...$interpolated, 'APP_ENV' => 'staging']); + $this->assertStringContainsString('Result cache not used because the metadata do not match', $process->getErrorOutput()); + } + + public function testRepointedEnvironmentFileLinksAreNoticed(): void + { + $applicationPath = $this->copyApplication(); + $unset = ['FIXTURE_GREETER' => false]; + mkdir($applicationPath . '/environments'); + file_put_contents($applicationPath . '/environments/english', "FIXTURE_GREETER=english\n"); + file_put_contents($applicationPath . '/environments/french', "FIXTURE_GREETER=french\n"); + symlink($applicationPath . '/environments/english', $applicationPath . '/.env'); + + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $unset)); + + // The file the link pointed to is unchanged, so only the link's own path shows the new values. + unlink($applicationPath . '/.env'); + symlink($applicationPath . '/environments/french', $applicationPath . '/.env'); + $french = ['Usage.php:33 staticMethod.notFound', 'Usage.php:38 return.type']; + $this->assertDiagnostics($french, $this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $unset)); + + $process = $this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $unset); + $this->assertDiagnostics($french, $process); + $this->assertReanalyzed(0, $process); + } + + public function testCachedConfigurationSkipsTheEnvironmentFileAndConfigFiles(): void + { + $applicationPath = $this->copyApplication(); + $unset = ['FIXTURE_GREETER' => false]; + $this->cacheConfiguration($applicationPath); + + // The boot neither loads nor validates the environment file while configuration is cached. + file_put_contents($applicationPath . '/.env', "BROKEN=\"unterminated\n"); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $unset)); + + file_put_contents($applicationPath . '/.env', "FIXTURE_GREETER=french\n"); + file_put_contents($applicationPath . '/config/unused.php', "analyse($applicationPath, parameters: self::PARAMETERS, environment: $unset); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $process); + $this->assertReanalyzed(0, $process); + + // Clearing the cache loads them again. + unlink($applicationPath . '/bootstrap/cache/config.php'); + $this->assertDiagnostics( + ['Usage.php:33 staticMethod.notFound', 'Usage.php:38 return.type'], + $this->analyse($applicationPath, parameters: self::PARAMETERS, environment: $unset), + ); + } + + public function testConfigDirectoriesThatStartOrStopExistingAreNoticed(): void + { + $applicationPath = $this->copyApplication(); + $parameters = self::PARAMETERS . "\nconfigDirectories:\n - '{$applicationPath}/config'\n - '{$applicationPath}/modules/*/config'\n - '{$applicationPath}/extra/config'"; + file_put_contents($applicationPath . '/app/ConfigUsage.php', <<<'PHP' + assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: $parameters)); + + mkdir($applicationPath . '/modules/billing/config', 0777, true); + file_put_contents($applicationPath . '/modules/billing/config/module.php', " 'none'];\n"); + $this->assertDiagnostics([...self::BOOT_DIAGNOSTICS, 'ConfigUsage.php:11 return.type'], $this->analyse($applicationPath, parameters: $parameters)); + + (new Filesystem)->deleteDirectory($applicationPath . '/modules/billing'); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: $parameters)); + + // A configured literal directory is watched before it exists. + mkdir($applicationPath . '/extra/config', 0777, true); + file_put_contents($applicationPath . '/extra/config/module.php', " 'none'];\n"); + $this->assertDiagnostics([...self::BOOT_DIAGNOSTICS, 'ConfigUsage.php:11 return.type'], $this->analyse($applicationPath, parameters: $parameters)); + } + + public function testViewNameChangesReanalyzeOnlyViewConsumers(): void + { + $applicationPath = $this->copyApplication(); + $viewsPath = $applicationPath . '/resources/views'; + + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: self::PARAMETERS)); + + // A free function's parameter, a class member's, a namespaced view and a global alias's. + file_put_contents($viewsPath . '/fixture-page.blade.php', 'Page'); + file_put_contents($viewsPath . '/mail-page.blade.php', 'Mail'); + file_put_contents($viewsPath . '/alias-view.blade.php', 'Alias'); + mkdir($applicationPath . '/resources/fixture-views'); + file_put_contents($applicationPath . '/resources/fixture-views/panel.blade.php', 'Panel'); + + $process = $this->analyse($applicationPath, parameters: self::PARAMETERS); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $process, []); + $this->assertReanalyzed(4, $process); + + unlink($viewsPath . '/fixture-page.blade.php'); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: self::PARAMETERS), ['ViewUsage.php:11 argument.type']); + + file_put_contents($viewsPath . '/fixture-page.blade.php', 'Page'); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, parameters: self::PARAMETERS), []); + + // Which views exist does not depend on their contents. + file_put_contents($viewsPath . '/welcome.blade.php', '

Welcome back

'); + $process = $this->analyse($applicationPath, parameters: self::PARAMETERS); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $process, []); + $this->assertReanalyzed(0, $process); + } + + public function testLinkedTemplateEditsRecheckTheirTranslations(): void + { + $applicationPath = $this->copyApplication(); + + // The view finder finds shared.panel through the link, so the translation rule checks it. + mkdir($applicationPath . '/lang/en', 0777, true); + file_put_contents($applicationPath . '/lang/en/fixture.php', " 'Hello'];\n"); + mkdir($applicationPath . '/resources/shared'); + file_put_contents($applicationPath . '/resources/shared/panel.blade.php', "{{ __('fixture.missing') }}\n"); + symlink($applicationPath . '/resources/shared', $applicationPath . '/resources/views/shared'); + + $this->assertDiagnostics( + [...self::BOOT_DIAGNOSTICS, 'panel.blade.php:1 hypervel.missingTranslations'], + $this->analyse($applicationPath, reuseCollectedDataRuleErrors: true, parameters: self::PARAMETERS), + ); + + // An edit that keeps the template's name runs the rule again. + file_put_contents($applicationPath . '/resources/shared/panel.blade.php', "{{ __('fixture.greeting') }}\n"); + $this->assertDiagnostics(self::BOOT_DIAGNOSTICS, $this->analyse($applicationPath, reuseCollectedDataRuleErrors: true, parameters: self::PARAMETERS)); + } + + /** + * Write the application's configuration cache as config:cache does. + */ + private function cacheConfiguration(string $applicationPath): void + { + $process = new Process([PHP_BINARY, '-r', <<<'PHP' + require $argv[1] . '/vendor/autoload.php'; + require $argv[2] . '/bootstrap/autoload.php'; + $app = require $argv[2] . '/bootstrap/app.php'; + $app->make(Hypervel\Contracts\Console\Kernel::class)->bootstrap(); + file_put_contents($app->getCachedConfigPath(), 'make('config')->all(), true) . ';' . PHP_EOL); + PHP, dirname(__DIR__, 2), $applicationPath], $applicationPath, ['FIXTURE_GREETER' => false]); + $process->mustRun(); + } + + /** + * Assert the run's diagnostics: the given ones plus the missing-view ones. + * + * @param list $expected + * @param null|list $views the missing-view diagnostics, all of them when null + */ + private function assertDiagnostics(array $expected, Process $process, ?array $views = null): void + { + $expected = [...$expected, ...($views ?? self::VIEW_DIAGNOSTICS)]; + sort($expected); + + $this->assertSame($expected, $this->diagnostics($process, $expected === [] ? 0 : 1)); + } +} diff --git a/tests/PHPStan/RuntimeInputsPackageDependencyTest.php b/tests/PHPStan/RuntimeInputsPackageDependencyTest.php new file mode 100644 index 0000000000..10b4bafcde --- /dev/null +++ b/tests/PHPStan/RuntimeInputsPackageDependencyTest.php @@ -0,0 +1,120 @@ +createPackage(); + $skeletonPath = $packagePath . '/skeleton'; + // The test models a standalone analysis, so it drops ParaTest's worker token, which + // would make Testbench give the SQLite connection a per-worker file. + $unset = ['PACKAGE_GREETER' => false, 'MYSQL_DATABASE' => false, 'TEST_TOKEN' => false]; + + $this->assertSame([self::ENGLISH], $this->diagnostics($this->analysePackage($packagePath, $unset), 1)); + + // The second run boots another runtime copy. + $process = $this->analysePackage($packagePath, $unset); + $this->assertSame([self::ENGLISH], $this->diagnostics($process, 1)); + $this->assertReanalyzed(0, $process); + + // A skeleton configuration file. + $this->edit($skeletonPath . '/config/package.php', "'english'", "'french'"); + $this->assertSame([], $this->diagnostics($this->analysePackage($packagePath, $unset), 0)); + $this->edit($skeletonPath . '/config/package.php', "'french'", "'english'"); + $this->assertSame([self::ENGLISH], $this->diagnostics($this->analysePackage($packagePath, $unset), 1)); + + // The Testbench YAML's environment. + $this->edit($packagePath . '/testbench.yaml', "hypervel: ./skeleton\n", "hypervel: ./skeleton\nenv:\n PACKAGE_GREETER: french\n"); + $this->assertSame([], $this->diagnostics($this->analysePackage($packagePath, $unset), 0)); + $this->edit($packagePath . '/testbench.yaml', "env:\n PACKAGE_GREETER: french\n", ''); + $this->assertSame([self::ENGLISH], $this->diagnostics($this->analysePackage($packagePath, $unset), 1)); + + // A database override Testbench reads from the process environment. + $this->assertSame([], $this->diagnostics($this->analysePackage($packagePath, [...$unset, 'MYSQL_DATABASE' => 'french']), 0)); + $this->assertSame([self::ENGLISH], $this->diagnostics($this->analysePackage($packagePath, $unset), 1)); + + // Testbench makes the testing connection the default while the skeleton's SQLite file is missing. + touch($skeletonPath . '/database/database.sqlite'); + $this->assertSame([], $this->diagnostics($this->analysePackage($packagePath, $unset), 0)); + unlink($skeletonPath . '/database/database.sqlite'); + $this->assertSame([self::ENGLISH], $this->diagnostics($this->analysePackage($packagePath, $unset), 1)); + + $this->assertStringNotContainsString("env-pattern\t*", (string) file_get_contents($packagePath . '/cache/resultCache.php')); + } + + /** + * Create a package whose Testbench YAML names a copy of the default skeleton. + */ + private function createPackage(): string + { + $packagePath = $this->tempDir . '/package'; + (new Filesystem)->copyDirectory(dirname(__DIR__, 2) . '/src/testbench/hypervel', $packagePath . '/skeleton'); + + file_put_contents($packagePath . '/skeleton/config/package.php', " env('PACKAGE_GREETER', 'english')];\n"); + file_put_contents($packagePath . '/testbench.yaml', "hypervel: ./skeleton\n"); + // The provider is declared only in the package's own Composer metadata, as an installing application would discover it. + file_put_contents($packagePath . '/composer.json', json_encode([ + 'name' => 'hypervel-phpstan/runtime-inputs-package', + 'extra' => ['hypervel' => ['providers' => [PackageServiceProvider::class]]], + ], JSON_THROW_ON_ERROR)); + + return $packagePath; + } + + /** + * Run PHPStan from the package over the package fixture directory, one file per job. + * + * @param array $environment variables to set, or to unset with false, over the test's own + */ + private function analysePackage(string $packagePath, array $environment): Process + { + $repositoryPath = dirname(__DIR__, 2); + $fixturePath = __DIR__ . '/Fixtures/RuntimeInputs/Package'; + + file_put_contents($packagePath . '/phpstan.neon', <<run(); + + return $process; + } +} diff --git a/tests/PHPStan/TestCase.php b/tests/PHPStan/TestCase.php new file mode 100644 index 0000000000..791928ae00 --- /dev/null +++ b/tests/PHPStan/TestCase.php @@ -0,0 +1,20 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** + * Get the fixtures' type assertions, analyzed against this test's own auth setup. + * + * @return iterable + */ + public static function dataFileAsserts(): iterable + { + $config = new Repository(['auth' => [ + 'guards' => [ + 'web' => ['driver' => 'session', 'provider' => 'users'], + 'legacy' => ['driver' => 'session', 'provider' => 'database'], + 'api' => ['driver' => 'token', 'provider' => 'database'], + 'members' => ['driver' => 'request', 'provider' => 'users'], + 'request' => ['driver' => 'request', 'provider' => 'database'], + 'remote' => ['driver' => 'session', 'provider' => 'remote'], + 'missing' => ['driver' => 'session', 'provider' => 'absent'], + 'providerless' => ['driver' => 'request'], + '0' => ['driver' => 'session', 'provider' => 'users'], + ], + 'providers' => [ + 'users' => ['driver' => 'eloquent', 'model' => User::class], + 'database' => ['driver' => 'database', 'table' => 'users'], + 'remote' => ['driver' => 'remote'], + ], + ]]); + + $app = new Container; + $app->instance('config', $config); + + $manager = new AuthManager($app); + $manager->viaRequest('request', static fn () => null); + $manager->extend('token', static fn (): RequestGuard => throw new LogicException('Analysis never creates guards.')); + $manager->extend('untyped', static fn () => throw new LogicException('Analysis never creates guards.')); + $manager->provider('remote', static fn () => throw new LogicException('Analysis never creates providers.')); + $app->instance('auth', $manager); + + // This test's own config file gives it its own instance of the helper, so the swapped + // container and its cached guard types never reach other tests. + self::getContainer()->getByType(AuthGuardHelper::class)->setContainer($app); + + yield from self::gatherAssertTypes(__DIR__ . '/data/auth-guards.php'); + + // Any guard the analyzer cannot type leaves the default guard with its declared type. + $config->set('auth.guards.untyped', ['driver' => 'untyped']); + + yield from self::gatherAssertTypes(__DIR__ . '/data/auth-untyped-guard.php'); + } + + /** + * Get the analyzer configuration files for this test. + * + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-auth-guards.neon']; + } +} diff --git a/tests/PHPStan/Type/BleedingEdgeTypeTest.php b/tests/PHPStan/Type/BleedingEdgeTypeTest.php new file mode 100644 index 0000000000..d66b3bf9dc --- /dev/null +++ b/tests/PHPStan/Type/BleedingEdgeTypeTest.php @@ -0,0 +1,44 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/bleeding-edge/collection-keys.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bleeding-edge/collection-usage-inference.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bleeding-edge/closure-usage-inference.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bleeding-edge/eloquent-collection-map.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + // PHPStan's own bleedingEdge.neon includes the config through a phar alias that only exists when the phar is executed. + $pharRoot = dirname((string) (new ReflectionClass(TypeInferenceTestCase::class))->getFileName(), 3); + + return [ + __DIR__ . '/../phpstan-tests.neon', + $pharRoot . '/conf/bleedingEdge.neon', + ]; + } +} diff --git a/tests/PHPStan/Type/BuilderOfTypeNodeResolverExtensionTest.php b/tests/PHPStan/Type/BuilderOfTypeNodeResolverExtensionTest.php new file mode 100644 index 0000000000..76c0ef4b67 --- /dev/null +++ b/tests/PHPStan/Type/BuilderOfTypeNodeResolverExtensionTest.php @@ -0,0 +1,198 @@ +extension = static::getContainer()->getByType(BuilderOfTypeNodeResolverExtension::class); + + $this->extension->setTypeNodeResolver(static::getContainer()->getByType(TypeNodeResolver::class)); + + $this->nameScope = new NameScope(null, []); + } + + public function testNonGenericTypeNodeReturnsNull(): void + { + $typeNode = new IdentifierTypeNode('string'); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNull($result); + } + + public function testNonBuilderOfGenericTypeReturnsNull(): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('collection-of'), + [new IdentifierTypeNode('User')], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNull($result); + } + + public function testBuilderOfWithNoGenericTypesReturnsNull(): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('builder-of'), + [], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNull($result); + } + + public function testBuilderOfWithTooManyGenericTypesReturnsNull(): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('builder-of'), + [ + new IdentifierTypeNode('App\User'), + new IdentifierTypeNode('string'), + new IdentifierTypeNode('string'), + ], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNull($result); + } + + #[DataProvider('invalidRelationTypesProvider')] + public function testBuilderOfWithInvalidRelationTypeReturnsNull(TypeNode $relationType): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('builder-of'), + [new IdentifierTypeNode('App\User'), $relationType], + ); + + $this->assertNull($this->extension->resolve($typeNode, $this->nameScope)); + } + + /** @return iterable */ + public static function invalidRelationTypesProvider(): iterable + { + foreach (['int', 'mixed', 'null', 'never', 'App\Account'] as $type) { + yield $type => [new IdentifierTypeNode($type)]; + } + + yield 'nullable string' => [new NullableTypeNode(new IdentifierTypeNode('string'))]; + yield 'string or int' => [new UnionTypeNode([new IdentifierTypeNode('string'), new IdentifierTypeNode('int')])]; + } + + public function testBuilderOfWithUnboundedRelationTemplateReturnsNull(): void + { + $template = TemplateTypeFactory::create( + TemplateTypeScope::createWithFunction('test'), + 'TRelation', + null, + TemplateTypeVariance::createInvariant(), + ); + $nameScope = $this->nameScope->withTemplateTypeMap(new TemplateTypeMap(['TRelation' => $template]), []); + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('builder-of'), + [new IdentifierTypeNode('App\User'), new IdentifierTypeNode('TRelation')], + ); + + $this->assertNull($this->extension->resolve($typeNode, $nameScope)); + } + + public function testRelationshipTypeSurvivesPhpDocSerialization(): void + { + $resolver = static::getContainer()->getByType(TypeStringResolver::class); + $type = $resolver->resolve("builder-of"); + + $this->assertSame("builder-of", $type->describe(VerbosityLevel::precise())); + $this->assertTrue($type->equals($resolver->resolve((string) $type->toPhpDocNode()))); + $this->assertFalse($type->equals($resolver->resolve("builder-of"))); + $this->assertFalse($type->equals($resolver->resolve('builder-of'))); + } + + public function testBuilderOfWithNonModelTypeReturnsNull(): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('builder-of'), + [new IdentifierTypeNode('string')], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNull($result); + } + + #[DataProvider('validModelTypesProvider')] + public function testBuilderOfWithValidModelTypes(string $modelClass, string $expectedDescription): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('builder-of'), + [new IdentifierTypeNode($modelClass)], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNotNull($result); + $this->assertInstanceOf(LateResolvableType::class, $result); + $this->assertStringContainsString($expectedDescription, $result->resolve()->describe(VerbosityLevel::value())); + } + + /** @return array */ + public static function validModelTypesProvider(): array + { + return [ + 'User model with standard builder' => [ + 'App\User', + 'Hypervel\Database\Eloquent\Builder', + ], + 'Post model with custom builder' => [ + 'App\Post', + 'App\PostBuilder', + ], + 'Account model with standard builder' => [ + 'App\Account', + 'Hypervel\Database\Eloquent\Builder', + ], + 'Team model with custom builder' => [ + 'App\Team', + 'App\ChildTeamBuilder', + ], + ]; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../phpstan-tests.neon', + ]; + } +} diff --git a/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php new file mode 100644 index 0000000000..17e3252e68 --- /dev/null +++ b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionTest.php @@ -0,0 +1,37 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-filter.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-reject.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-where.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-narrowing-boundaries.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-where-not-null.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-nullsafe-chain.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../../src/phpstan/extension.neon']; + } +} diff --git a/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php new file mode 100644 index 0000000000..1d1fc61c6e --- /dev/null +++ b/tests/PHPStan/Type/CollectionDynamicReturnTypeExtensionsTest.php @@ -0,0 +1,40 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-helper.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-make-static.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-stubs.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-key-types.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-generic-static-methods.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-collection-map.php'); + // REMOVED: collection-generic-static-methods-l11.php; Laravel 11's Eloquent partition() returned static, and Hypervel follows the Laravel 12 base collection. + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-generic-static-methods-l12.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-stubs-l12-20.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/CollectionOfTypeNodeResolverExtensionTest.php b/tests/PHPStan/Type/CollectionOfTypeNodeResolverExtensionTest.php new file mode 100644 index 0000000000..cb8ebeb5ba --- /dev/null +++ b/tests/PHPStan/Type/CollectionOfTypeNodeResolverExtensionTest.php @@ -0,0 +1,181 @@ +extension = static::getContainer()->getByType(CollectionOfTypeNodeResolverExtension::class); + + $this->extension->setTypeNodeResolver(static::getContainer()->getByType(TypeNodeResolver::class)); + + $this->nameScope = new NameScope(null, []); + } + + public function testNonGenericTypeNodeReturnsNull(): void + { + $typeNode = new IdentifierTypeNode('string'); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNull($result); + } + + public function testNonCollectionOfGenericTypeReturnsNull(): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('model-property'), + [new IdentifierTypeNode('User')], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNull($result); + } + + public function testCollectionOfWithNoGenericTypesReturnsErrorType(): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('collection-of'), + [], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNull($result); + } + + public function testCollectionOfWithMultipleGenericTypesReturnsErrorType(): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('collection-of'), + [ + new IdentifierTypeNode('User'), + new IdentifierTypeNode('Account'), + ], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNull($result); + } + + public function testCollectionOfWithKeyType(): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('collection-of'), + [ + new IdentifierTypeNode('string'), + new IdentifierTypeNode('\App\User'), + ], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNotNull($result); + $this->assertInstanceOf(LateResolvableType::class, $result); + $this->assertSame('Hypervel\Database\Eloquent\Collection', $result->resolve()->describe(VerbosityLevel::precise())); + } + + public function testCollectionOfWithNonModelTypeReturnsErrorType(): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('collection-of'), + [new IdentifierTypeNode('string')], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNull($result); + } + + #[DataProvider('validModelTypesProvider')] + public function testCollectionOfWithValidModelTypes(string $modelClass, string $expectedDescription): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('collection-of'), + [new IdentifierTypeNode($modelClass)], + ); + + $result = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNotNull($result); + $this->assertInstanceOf(LateResolvableType::class, $result); + $this->assertStringContainsString($expectedDescription, $result->resolve()->describe(VerbosityLevel::value())); + } + + /** @return array */ + public static function validModelTypesProvider(): array + { + return [ + 'User model with standard collection' => [ + 'App\User', + 'Collection<(int|string), App\User>', + ], + 'Post model with standard collection' => [ + 'App\Post', + 'Collection<(int|string), App\Post>', + ], + 'Transaction model with custom collection' => [ + 'App\Transaction', + 'App\TransactionCollection', + ], + 'Account model with custom collection' => [ + 'App\Account', + 'App\AccountCollection', + ], + ]; + } + + public function testCollectionKeysAreCompatibleWithIntegerAndStringKeys(): void + { + $typeNode = new GenericTypeNode( + new IdentifierTypeNode('collection-of'), + [new IdentifierTypeNode('App\Account')], + ); + + $collection = $this->extension->resolve($typeNode, $this->nameScope); + + $this->assertNotNull($collection); + $this->assertInstanceOf(BenevolentUnionType::class, $collection->getIterableKeyType()); + + foreach ([new IntegerType, new StringType] as $keyType) { + $typedCollection = new GenericObjectType('App\AccountCollection', [$keyType, new ObjectType('App\Account')]); + + $this->assertTrue($typedCollection->accepts($collection, true)->yes()); + $this->assertTrue($collection->accepts($typedCollection, true)->yes()); + } + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../phpstan-tests.neon', + ]; + } +} diff --git a/tests/PHPStan/Type/ConfigDynamicReturnTypeExtensionTest.php b/tests/PHPStan/Type/ConfigDynamicReturnTypeExtensionTest.php new file mode 100644 index 0000000000..99ebc12485 --- /dev/null +++ b/tests/PHPStan/Type/ConfigDynamicReturnTypeExtensionTest.php @@ -0,0 +1,104 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/config-helper-function.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-repository-method.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-reproduction.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-facade-collection-method.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-repository-method-l12-20.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-provider-sources.php'); + } + + public function testConfigSourcesAreDependencies(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $file = __DIR__ . '/data/config-file-dependency.php'; + $fileHelper = self::getContainer()->getByType(FileHelper::class); + $runtimeInputs = self::getContainer()->getByType(RuntimeInputs::class); + + // Every config directory's candidate files and the directory below each key, and the framework files + // merged beneath them. A path in Testbench's runtime copy is tracked through its unchanged skeleton source. + $expected = []; + + foreach (['auth', 'test', 'missing'] as $name) { + $expected[] = [FileResultCacheValueExtension::class, $fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath() . '/' . $name . '.php')]; + } + + foreach (self::getContainer()->getByType(ConfigSourceResolver::class)->getConfigDirectories() as $configPath) { + foreach (['auth', 'auth/defaults', 'test', 'test/foo', 'missing', 'missing/foo'] as $name) { + $expected[] = [FileResultCacheValueExtension::class, $runtimeInputs->sourcePath($fileHelper->normalizePath($configPath . '/' . $name . '.php'))]; + } + + foreach (['auth/defaults', 'test/foo', 'missing/foo'] as $name) { + $expected[] = [ + DirectoryResultCacheValueExtension::class, + DirectoryResultCacheValueExtension::createKey($runtimeInputs->sourceDirectory($fileHelper->normalizePath($configPath . '/' . $name), '*.php'), '*.php'), + ]; + } + } + + sort($expected); + + // The second run hits the parsed config cache and must still declare the sources. + foreach ([1, 2] as $run) { + $collector->startFile($file); + self::processFile($file, static function (): void { + }); + $dependencies = []; + $bootDependencies = 0; + + foreach ($collector->finishFile()['values'] as [$extensionClass, $key]) { + // The booted providers decide the config directories, merges and writes. + if ($extensionClass === RuntimeInputsResultCacheValueExtension::class) { + ++$bootDependencies; + + continue; + } + + $dependencies[] = [$extensionClass, $key]; + } + + sort($dependencies); + + $this->assertSame($expected, $dependencies, sprintf('Run %d', $run)); + $this->assertSame(1, $bootDependencies, sprintf('Run %d', $run)); + } + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/config-with-config-paths.neon']; + } +} diff --git a/tests/PHPStan/Type/ConfigFileResolutionTest.php b/tests/PHPStan/Type/ConfigFileResolutionTest.php new file mode 100644 index 0000000000..9c5b5fbc62 --- /dev/null +++ b/tests/PHPStan/Type/ConfigFileResolutionTest.php @@ -0,0 +1,95 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/config-file-resolution.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/config-file-scope.php'); + } + + public function testEveryFileThatCanHoldAKeyIsAFileDependency(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $file = __DIR__ . '/data/config-file-resolution.php'; + + $collector->startFile($file); + self::processFile($file, static function (): void { + }); + $dependencyFiles = array_column(array_filter( + $collector->finishFile()['values'], + static fn (array $value): bool => $value[0] === FileResultCacheValueExtension::class, + ), 1); + + $fileHelper = self::getContainer()->getByType(FileHelper::class); + + // config('nested.rcnest.v') can come from any prefix of the key in any config directory + foreach (['config', 'modules/a/config', 'modules/b/config', 'literal[1]/config'] as $configDirectory) { + foreach (['nested.php', 'nested/rcnest.php', 'nested/rcnest/v.php'] as $configFile) { + $this->assertContains( + $fileHelper->normalizePath(__DIR__ . '/data/config-file-resolution/' . $configDirectory . '/' . $configFile), + $dependencyFiles, + ); + } + } + + foreach ($dependencyFiles as $dependencyFile) { + $this->assertStringNotContainsString('*', $dependencyFile); + } + } + + public function testConfigSourcesReadByAConfigFileAreDependencies(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $file = __DIR__ . '/data/config-file-scope-dependency.php'; + $fileHelper = self::getContainer()->getByType(FileHelper::class); + $leaf = $fileHelper->normalizePath(__DIR__ . '/data/config-file-resolution/config/scopeleaf.php'); + // A file created below the key the config file reads changes that key's value. + $leafDirectory = DirectoryResultCacheValueExtension::createKey( + $fileHelper->normalizePath(__DIR__ . '/data/config-file-resolution/config/scopeleaf/v'), + '*.php', + ); + + // The second run reads the parsed config cache and must still declare the sources the config file reads. + foreach ([1, 2] as $run) { + $collector->startFile($file); + self::processFile($file, static function (): void { + }); + $dependencies = array_column($collector->finishFile()['values'], 1); + + $this->assertContains($leaf, $dependencies, sprintf('Run %d', $run)); + $this->assertContains($leafDirectory, $dependencies, sprintf('Run %d', $run)); + } + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/config-file-resolution.neon']; + } +} diff --git a/tests/PHPStan/Type/GeneralTypeTest.php b/tests/PHPStan/Type/GeneralTypeTest.php new file mode 100644 index 0000000000..0470be1be7 --- /dev/null +++ b/tests/PHPStan/Type/GeneralTypeTest.php @@ -0,0 +1,152 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/abort.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/abstract-manager.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/app-make.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/application-make.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/arr-get-pull.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/arr-map.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/arrayable.php'); + // Upstream never registers artisan.php. + yield from self::gatherAssertTypes(__DIR__ . '/data/artisan.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/auth.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/benchmark.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/builder-of-type.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-1346.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-1565.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-1718.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-1760.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-1819.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-1830.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-1985.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-1997.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-2057.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-2073.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-2111.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-2188.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-2283.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/collection-of-type.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/concurrency-drivers.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/conditionable.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/console-commands.php'); + // REMOVED: container-array-access.php; container ArrayAccess is unsupported. + yield from self::gatherAssertTypes(__DIR__ . '/data/container-make.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/contracts.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/custom-eloquent-builder.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/custom-eloquent-collection.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/custom-query-collections.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/database-transaction.php'); + // REMOVED: date-extension-l11.php and date-extension-l12.php; DateExtension is omitted because the Date facade declares CarbonInterface. + // Hypervel: covers the per-key eager-load, aggregate and morph map types. + yield from self::gatherAssertTypes(__DIR__ . '/data/eager-load-maps.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eager-loading-callbacks.php'); + // REMOVED: eloquent-builder.php; it asserts Laravel releases before 11.42. + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-builder-l11-42.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-builder-l12-19.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-builder-l13-31.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-getter-types.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/eloquent-where.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/environment-helper.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/event-dispatcher.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/facade-fakes.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/facades.php'); + // REMOVED: facades-l11-42.php; it asserts Laravel 11's Redis facade docblock types. + yield from self::gatherAssertTypes(__DIR__ . '/data/facades-l12-20.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/facades-l12-41.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/form-request.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/forwarded-references.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/gate-facade.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/has-events.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/helpers.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/higher-order-collection-proxies.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/higher-order-collection-proxy-methods.php'); + // Hypervel: covers the overrides that repeat method templates for PHPStan 2.3.5. + yield from self::gatherAssertTypes(__DIR__ . '/data/inherited-method-templates.php'); + // REMOVED: logger.php; upstream never registers it, and Logger declares no getHandlers(). + // REMOVED: model.php; it asserts Laravel releases before 11.42. + yield from self::gatherAssertTypes(__DIR__ . '/data/model-l11-42.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-collections-l11-28.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-collections-l13-4.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-date-column-casts.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-factories.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-increment-each-l13-3.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-increment-each-l13-20.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-methods.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties-relations.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties-l13.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties-l13-2.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-relations.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-serialization.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-serialization-l13.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-scope-attribute-l12.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-scopes.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-template-class-string.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/model-with-relations.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/managers.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/migrator.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/optional-helper.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/paginator-extension.php'); + // REMOVED: passthru.php; it asserts Laravel releases before 12.15. + yield from self::gatherAssertTypes(__DIR__ . '/data/passthru-l12-15.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/query-builder.php'); + // Upstream runs this per redisDriver setting; Hypervel only has phpredis, so it runs once here. + yield from self::gatherAssertTypes(__DIR__ . '/data/redis-driver-phpredis.php'); + // REMOVED: RedisDriverNullTypeTest.php and RedisDriverPredisTypeTest.php; the redisDriver setting is omitted. + yield from self::gatherAssertTypes(__DIR__ . '/data/relationship-query-callbacks.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/relation-of-type.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/request-header.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/request-object.php'); + // Upstream never registers request-user.php, which shares request-object.php's namespace. + yield from self::gatherAssertTypes(__DIR__ . '/data/request-user.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/route.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/storage-disks.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/tappable.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/throw.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/translate.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/translator.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/validator.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/view-exists.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/view.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/where-relation.php'); + + // Console Commands + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/BarCommand.php'); + // Upstream never registers BarCommandWithAttribute.php. + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/BarCommandWithAttribute.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/BazCommand.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/FooCommand.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/RegisteredInKernelCommand.php'); + // Moved from upstream's LoadedCommands directory: Hypervel's kernel discovers app/Console/Commands. + yield from self::gatherAssertTypes(__DIR__ . '/../application/app/Console/Commands/LoadedByKernelCommand.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Console/LookupCommand.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Console/WithArgumentCommand.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/GenericModelPropertyTypeTest.php b/tests/PHPStan/Type/GenericModelPropertyTypeTest.php new file mode 100644 index 0000000000..de5d37fc28 --- /dev/null +++ b/tests/PHPStan/Type/GenericModelPropertyTypeTest.php @@ -0,0 +1,199 @@ +assertSame($expected, $left->equals($right)); + $this->assertSame($expected, $right->equals($left)); + } + + /** @return iterable */ + public static function dataEquals(): iterable + { + $modelPropertyHelper = static::getContainer()->getByType(ModelPropertyHelper::class); + + yield [ + new GenericModelPropertyType(new ObjectType(User::class), $modelPropertyHelper), + new GenericModelPropertyType(new ObjectType(User::class), $modelPropertyHelper), + true, + ]; + + yield [ + new GenericModelPropertyType(new ObjectType(User::class), $modelPropertyHelper), + new GenericModelPropertyType(new ObjectType(Account::class), $modelPropertyHelper), + false, + ]; + } + + /** @param class-string $expectedTypeClass */ + #[DataProvider('dataUnion')] + public function testUnion( + callable $types, + string $expectedTypeClass, + string $expectedTypeDescription, + ): void { + $types = $types(static::getContainer()); + + $actualType = TypeCombinator::union(...$types); + $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); + + $this->assertSame( + $expectedTypeDescription, + $actualTypeDescription, + sprintf('union(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), + ); + + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + /** @param class-string $expectedTypeClass */ + #[DataProvider('dataUnion')] + public function testUnionInversed( + callable $types, + string $expectedTypeClass, + string $expectedTypeDescription, + ): void { + $types = array_reverse($types(static::getContainer())); + $actualType = TypeCombinator::union(...$types); + $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); + + $this->assertSame( + $expectedTypeDescription, + $actualTypeDescription, + sprintf('union(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), + ); + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + /** @return iterable, string}> */ + public static function dataUnion(): iterable + { + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new GenericModelPropertyType(new ObjectType(Account::class), $container->getByType(ModelPropertyHelper::class)), + ], + UnionType::class, + 'model property of App\Account|model property of App\User', + ]; + + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new StringType, + ], + StringType::class, + 'string', + ]; + + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new ConstantStringType('email'), + ], + GenericModelPropertyType::class, + 'model property of App\User', + ]; + } + + /** @param class-string $expectedTypeClass */ + #[DataProvider('dataIntersect')] + public function testIntersect( + callable $types, + string $expectedTypeClass, + string $expectedTypeDescription, + ): void { + $types = $types(static::getContainer()); + + $actualType = TypeCombinator::intersect(...$types); + $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); + + $this->assertSame($expectedTypeDescription, $actualTypeDescription); + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + /** @param class-string $expectedTypeClass */ + #[DataProvider('dataIntersect')] + public function testIntersectInversed( + callable $types, + string $expectedTypeClass, + string $expectedTypeDescription, + ): void { + $actualType = TypeCombinator::intersect(...array_reverse($types(static::getContainer()))); + $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); + + $this->assertSame($expectedTypeDescription, $actualTypeDescription); + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + /** @return iterable, string}> */ + public static function dataIntersect(): iterable + { + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new GenericModelPropertyType(new ObjectType(Account::class), $container->getByType(ModelPropertyHelper::class)), + ], + NeverType::class, + '*NEVER*', + ]; + + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new StringType, + ], + GenericModelPropertyType::class, + 'model property of App\User', + ]; + + yield [ + static fn ($container) => [ + new GenericModelPropertyType(new ObjectType(User::class), $container->getByType(ModelPropertyHelper::class)), + new ConstantStringType('email'), + ], + ConstantStringType::class, + "'email'", + ]; + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../src/phpstan/extension.neon', + ]; + } +} diff --git a/tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php b/tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php new file mode 100644 index 0000000000..26888ed9e5 --- /dev/null +++ b/tests/PHPStan/Type/MethodsClassReflectionExtensionTest.php @@ -0,0 +1,34 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + /** @return iterable */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/macros.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/redirect-response.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/macros-php-81.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/ModelCastsParsingTest.php b/tests/PHPStan/Type/ModelCastsParsingTest.php new file mode 100644 index 0000000000..b30962fb6c --- /dev/null +++ b/tests/PHPStan/Type/ModelCastsParsingTest.php @@ -0,0 +1,33 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function dataFileAsserts(): Generator + { + yield from self::gatherAssertTypes(__DIR__ . '/data/model-casts-parsing.php'); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + // Upstream's config-with-migrations-and-parsing-casts.neon matches the test defaults. + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/RelationOfTypeNodeResolverExtensionTest.php b/tests/PHPStan/Type/RelationOfTypeNodeResolverExtensionTest.php new file mode 100644 index 0000000000..f6f74df4b0 --- /dev/null +++ b/tests/PHPStan/Type/RelationOfTypeNodeResolverExtensionTest.php @@ -0,0 +1,98 @@ +extension = self::getContainer()->getByType(RelationOfTypeNodeResolverExtension::class); + $this->extension->setTypeNodeResolver(self::getContainer()->getByType(TypeNodeResolver::class)); + } + + #[DataProvider('invalidTypes')] + public function testInvalidTypesReturnNull(TypeNode $node): void + { + $this->assertNull($this->extension->resolve($node, new NameScope(null, []))); + } + + /** @return iterable */ + public static function invalidTypes(): iterable + { + yield 'not generic' => [new IdentifierTypeNode('string')]; + yield 'other type' => [new GenericTypeNode(new IdentifierTypeNode('builder-of'), [new IdentifierTypeNode('App\User')])]; + + foreach ([0, 1, 3] as $count) { + yield 'arity ' . $count => [new GenericTypeNode(new IdentifierTypeNode('relation-of'), array_fill(0, $count, new IdentifierTypeNode('App\User')))]; + } + + foreach (['string', 'int', 'stdClass', 'never'] as $model) { + yield 'model ' . $model => [new GenericTypeNode(new IdentifierTypeNode('relation-of'), [new IdentifierTypeNode($model), new IdentifierTypeNode('string')])]; + } + + foreach (['int', 'mixed', 'null', 'never', 'App\User'] as $key) { + yield 'key ' . $key => [new GenericTypeNode(new IdentifierTypeNode('relation-of'), [new IdentifierTypeNode('App\User'), new IdentifierTypeNode($key)])]; + } + + yield 'nullable key' => [new GenericTypeNode(new IdentifierTypeNode('relation-of'), [new IdentifierTypeNode('App\User'), new NullableTypeNode(new IdentifierTypeNode('string'))])]; + } + + public function testUnboundedKeyTemplateReturnsNull(): void + { + $template = TemplateTypeFactory::create(TemplateTypeScope::createWithFunction('test'), 'TKey', null, TemplateTypeVariance::createInvariant()); + $nameScope = (new NameScope(null, []))->withTemplateTypeMap(new TemplateTypeMap(['TKey' => $template]), []); + $node = new GenericTypeNode(new IdentifierTypeNode('relation-of'), [new IdentifierTypeNode('App\User'), new IdentifierTypeNode('TKey')]); + + $this->assertNull($this->extension->resolve($node, $nameScope)); + } + + public function testPhpDocRoundTripPreservesTypeAndDistinguishesBuilders(): void + { + $resolver = self::getContainer()->getByType(TypeStringResolver::class); + $relation = $resolver->resolve("relation-of"); + $builder = $resolver->resolve("builder-of"); + + $this->assertSame("relation-of", $relation->describe(VerbosityLevel::precise())); + $this->assertTrue($relation->equals($resolver->resolve((string) $relation->toPhpDocNode()))); + $this->assertFalse($relation->equals($resolver->resolve("relation-of"))); + $this->assertFalse($relation->equals($builder)); + $this->assertFalse($builder->equals($relation)); + } + + /** @return list */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-tests.neon']; + } +} diff --git a/tests/PHPStan/Type/data/FailsAtRuntime.php b/tests/PHPStan/Type/data/FailsAtRuntime.php new file mode 100644 index 0000000000..58f62399ea --- /dev/null +++ b/tests/PHPStan/Type/data/FailsAtRuntime.php @@ -0,0 +1,13 @@ + 0) { + $operator = '+'; + } elseif ($foo < 0) { + $operator = '-'; + } else { + abort(422); + } + + // Should not complain about $operator possibly not being defined + assertVariableCertainty( + \PHPStan\TrinaryLogic::createYes(), + $operator + ); + + abort_if(! $foo, 500); + assertType('int|int<1, max>', $foo); + + /** @var int|string $foo */ + abort_if(is_string($foo), 500); + assertType('int', $foo); + + /** @var ?int $foo */ + abort_unless(! is_null($foo), 500); + assertType('int', $foo); + + /** @var int|string $foo */ + abort_unless(! is_string($foo), 500); + assertType('int', $foo); + + // Hypervel: the condition binds by name. + /** @var ?int $foo */ + abort_if(code: 500, boolean: $foo === null); + assertType('int', $foo); +} + +/** + * Hypervel: an unpacked argument array is not the condition. + * + * @param array{bool, int} $arguments + */ +function unpackedArguments(array $arguments): void +{ + abort_if(...$arguments); + assertType('array{bool, int}', $arguments); +} diff --git a/tests/PHPStan/Type/data/abstract-manager.php b/tests/PHPStan/Type/data/abstract-manager.php new file mode 100644 index 0000000000..20938b6b98 --- /dev/null +++ b/tests/PHPStan/Type/data/abstract-manager.php @@ -0,0 +1,23 @@ + $model */ +function test(Application $app, ApplicationContract $app2, string $model): void +{ + assertType('Hypervel\Config\Repository', $app->make(Repository::class)); + assertType('Hypervel\Config\Repository', $app->makeWith(Repository::class)); + assertType('Hypervel\Config\Repository', $app->resolve(Repository::class)); + assertType('Hypervel\Tests\PHPStan\Type\data\FailsAtRuntime', $app->resolve(FailsAtRuntime::class)); + + assertType('Hypervel\Config\Repository', $app2->make(Repository::class)); + // REMOVED: makeWith() and resolve() on the contract; they are concrete container + // methods, and contracts are not given implementation methods. + assertType('Hypervel\Tests\PHPStan\Type\data\FailsAtRuntime', $app2->make(FailsAtRuntime::class)); + + // Upstream asserts mixed here; Hypervel keeps the declared class-string result. + assertType('Hypervel\Database\Eloquent\Model', $app->make($model)); + assertType('Hypervel\Database\Eloquent\Model', $app->makeWith($model)); + assertType('Hypervel\Database\Eloquent\Model', $app->resolve($model)); +} diff --git a/tests/PHPStan/Type/data/arr-get-pull.php b/tests/PHPStan/Type/data/arr-get-pull.php new file mode 100644 index 0000000000..e2d2fa74e1 --- /dev/null +++ b/tests/PHPStan/Type/data/arr-get-pull.php @@ -0,0 +1,195 @@ + */ +class ArrGetPullAccess implements \ArrayAccess +{ + public function offsetExists(mixed $offset): bool + { + return true; + } + + public function offsetGet(mixed $offset): int + { + return 1; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + } + + public function offsetUnset(mixed $offset): void + { + } +} + +/** + * @param array{foo: string, bar?: int, nullable: string|null, nested: array{deep: bool, maybe?: int}, word: string} $shape + * @param list $rows + * @param array $map + * @param Collection $collection + * @param array{a: int}|null $nullableShape + * @param 'foo'|'bar' $fooOrBar + */ +function get( + array $shape, + array $rows, + array $map, + Collection $collection, + ?array $nullableShape, + ?string $nullableKey, + string $anyKey, + int $anyInt, + string $fooOrBar, +): void { + assertType('string', Arr::get($shape, 'foo')); + assertType('int|null', Arr::get($shape, 'bar')); + assertType('string|null', Arr::get($shape, 'nullable', 'default')); + assertType('null', Arr::get($shape, 'missing')); + assertType("'default'", Arr::get($shape, 'missing', 'default')); + assertType('string|null', Arr::get($shape, $fooOrBar === 'foo' ? 'foo' : 'missing')); + assertType('int|string|null', Arr::get($shape, $fooOrBar)); + assertType('string', Arr::get(key: 'foo', array: $shape)); + + // "dot" notation + assertType('bool', Arr::get($shape, 'nested.deep')); + assertType('int|null', Arr::get($shape, 'nested.maybe')); + assertType('null', Arr::get($shape, 'nested.missing')); + assertType('null', Arr::get($shape, 'word.0')); + assertType('string|null', Arr::get($rows, '0.name')); + + // Other keys keep the declared type + assertType('mixed', Arr::get($shape, null)); + assertType('mixed', Arr::get($shape, $nullableKey)); + assertType('mixed', Arr::get($map, $anyKey)); + + // Defaults go through value(), which only calls a Closure + assertType('1', Arr::get($shape, 'missing', static fn () => 1)); + assertType("'strtoupper'", Arr::get($shape, 'missing', 'strtoupper')); + + assertType('array{name: string}|null', Arr::get($rows, $anyInt)); + assertType('int|null', Arr::get($collection, 'key')); + assertType('int|null', Arr::get($nullableShape, 'a')); + + // Unsealed shapes may hold any other key + assertType('mixed', Arr::get((new User())->toArray(), 'anything')); +} + +/** + * @param array{'a.b': int, a: array{b: string}} $dotKey + * @param array{'a.b'?: int, a: array{b: string}} $maybeDotKey + * @param array{foo: string}|ArrGetPullAccess $shapeOrAccess + */ +function getLiteralDotKeysAndArrayAccess(array $dotKey, array $maybeDotKey, array|ArrGetPullAccess $shapeOrAccess, ArrGetPullAccess $access): void +{ + // A literal key wins over the "dot" path + assertType('int', Arr::get($dotKey, 'a.b')); + assertType('int|string', Arr::get($maybeDotKey, 'a.b')); + + assertType('int|null', Arr::get($access, 'key')); + assertType('int|null', Arr::get($access, '0')); + assertType('int|string|null', Arr::get($shapeOrAccess, 'foo')); +} + +/** + * @param array{foo: string, bar?: int, nested: array{deep: bool, keep: int}, optional?: array{deep: bool}, nullable: array{deep: bool}|null, 'dot.key': int} $shape + * @param list $list + * @param array $map + * @param 'foo'|'bar' $fooOrBar + */ +function pull(array $shape, array $list, array $map, string $anyKey, string $fooOrBar): void +{ + $array = $shape; + assertType('string', Arr::pull($array, 'foo')); + assertType("array{bar?: int, nested: array{deep: bool, keep: int}, optional?: array{deep: bool}, nullable: array{deep: bool}|null, 'dot.key': int}", $array); + + $array = $shape; + assertType('int|null', Arr::pull($array, 'bar')); + assertType("array{foo: string, nested: array{deep: bool, keep: int}, optional?: array{deep: bool}, nullable: array{deep: bool}|null, 'dot.key': int}", $array); + + $array = $shape; + assertType("'default'", Arr::pull($array, 'missing', 'default')); + assertType("array{foo: string, bar?: int, nested: array{deep: bool, keep: int}, optional?: array{deep: bool}, nullable: array{deep: bool}|null, 'dot.key': int}", $array); + + $array = $shape; + Arr::pull($array, $fooOrBar); + assertType("array{foo?: string, bar?: int, nested: array{deep: bool, keep: int}, optional?: array{deep: bool}, nullable: array{deep: bool}|null, 'dot.key': int}", $array); + + // Keys in "dot" notation keep the declared type + $array = $shape; + assertType('bool', Arr::pull($array, 'nested.deep')); + assertType('array', $array); + + assertType('int|null', Arr::pull($list, 0)); + assertType('array, int>', $list); + + assertType('mixed', Arr::pull($map, $anyKey)); + assertType('array', $map); +} + +/** + * @param list $list + * @param '0'|'1' $zeroOrOne + */ +function pullNumericStringKeys(array $list, string $zeroOrOne): void +{ + $array = $list; + Arr::pull($array, '0'); + assertType('array, string>', $array); + + $array = $list; + Arr::pull($array, '1'); + assertType('array<0|int<2, max>, string>', $array); + + $array = $list; + Arr::pull($array, $zeroOrOne); + assertType('array, string>', $array); +} + +/** @param array{'a.b': int, a: array{b: string}, c: int} $dotKey */ +function pullLiteralDotKey(array $dotKey): void +{ + assertType('int', Arr::pull($dotKey, 'a.b')); + assertType('array{a: array{b: string}, c: int}', $dotKey); +} + +class ArrGetPullOverridingArr extends Arr +{ + /** @return 'overridden' */ + public static function get(mixed $array, string|int|float|null $key, mixed $default = null): mixed + { + return 'overridden'; + } +} + +class ArrGetPullOverridingForget extends Arr +{ + public static function forget(array &$array, array|string|int|float|null $keys): void + { + } +} + +/** @param array{foo: string} $shape */ +function overriddenGet(array $shape): void +{ + assertType("'overridden'", ArrGetPullOverridingArr::get($shape, 'missing')); + + // The inherited pull() reads through the overriding get(). + assertType('mixed', ArrGetPullOverridingArr::pull($shape, 'foo')); +} + +/** @param array{foo: string, bar: int} $shape */ +function overriddenForget(array $shape): void +{ + assertType('string', ArrGetPullOverridingForget::pull($shape, 'foo')); + + // The inherited pull() removes the key through the overriding forget(). + assertType('array', $shape); +} diff --git a/tests/PHPStan/Type/data/arr-map.php b/tests/PHPStan/Type/data/arr-map.php new file mode 100644 index 0000000000..c0b4750cca --- /dev/null +++ b/tests/PHPStan/Type/data/arr-map.php @@ -0,0 +1,48 @@ + $items + */ +function test(array $items, string $label): void +{ + assertType('array', Arr::map($items, static function (int $value, string $key) use ($label): string { + return $label; + })); + + assertType('array>', Arr::map($items, static function ($value, $key) { + assertType('int', $value); + assertType('string', $key); + + return [$key => $value]; + })); + + assertType('array', Arr::map($items, static fn ($value) => (string) $value)); + assertType('array', Arr::map($items, 'strval')); +} + +/** + * @param array $items + * @param list $users + */ +function testMapWithKeys(array $items, array $users): void +{ + assertType('array', Arr::mapWithKeys($items, static function ($value, $key) { + assertType('int', $value); + assertType('string', $key); + + return [$key => (string) $value]; + })); + + assertType('array', Arr::mapWithKeys($items, static fn (int $value, string $key): array => [$value => $key])); + + assertType("array<'total', int>", Arr::mapWithKeys($items, static fn (int $value): array => ['total' => $value])); + // Unlike upstream, the key is the name column: User::email() is an accessor Eloquent applies, so email reads as its untyped callback. + assertType('array', Arr::mapWithKeys($users, static fn (User $user): array => [$user->name => $user])); +} diff --git a/tests/PHPStan/Type/data/arrayable.php b/tests/PHPStan/Type/data/arrayable.php new file mode 100644 index 0000000000..4d8651b7f6 --- /dev/null +++ b/tests/PHPStan/Type/data/arrayable.php @@ -0,0 +1,42 @@ +> */ +class Counts implements Arrayable +{ + public function toArray(): array + { + return ['total' => 1]; + } +} + +/** @implements Arrayable> */ +class Totals implements Arrayable +{ + /** @return array{total: int} */ + public function toArray(): array + { + return ['total' => 1]; + } +} + +/** + * @param Arrayable> $arrayable + * @param Collection $users + */ +function test(Counts $counts, Totals $totals, Arrayable $arrayable, Collection $users): void +{ + assertType('array', $counts->toArray()); + assertType('array{total: int}', $totals->toArray()); + assertType('array', $arrayable->toArray()); + assertType('array', $users->toArray()); +} diff --git a/tests/PHPStan/Type/data/artisan.php b/tests/PHPStan/Type/data/artisan.php new file mode 100644 index 0000000000..b393129049 --- /dev/null +++ b/tests/PHPStan/Type/data/artisan.php @@ -0,0 +1,16 @@ +describe('Foo Bar'); +} diff --git a/tests/PHPStan/Type/data/auth-guards.php b/tests/PHPStan/Type/data/auth-guards.php new file mode 100644 index 0000000000..c47ab9eeb2 --- /dev/null +++ b/tests/PHPStan/Type/data/auth-guards.php @@ -0,0 +1,91 @@ + $names + * @param non-empty-list $someNames + */ +function test(string $name, ?string $maybeName, array $names, array $someNames, ApiAuthManager $apiAuthManager): void +{ + // shouldUse() changes the default guard per coroutine, so it may be any configured guard. + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', Auth::guard()); + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', Auth::guard(null)); + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', Auth::guard('')); + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', auth()->guard()); + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', auth('')); + + // Resolving a guard that is not configured throws, so an unknown name may be any configured guard. + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', Auth::guard($name)); + assertType('Hypervel\Auth\RequestGuard|Hypervel\Auth\SessionGuard', Auth::guard(...$names)); + + // auth() returns the factory for a null or omitted guard, so these keep the declaration. + assertType('Hypervel\Contracts\Auth\Factory|Hypervel\Contracts\Auth\Guard', auth($maybeName)); + + // An empty unpacked list leaves the guard null, so auth() may return the factory. + assertType('Hypervel\Auth\AuthManager|Hypervel\Contracts\Auth\Guard', auth(...$names)); + assertType('Hypervel\Contracts\Auth\Guard', auth(...$someNames)); + + assertType(SessionGuard::class, Auth::guard('0')); + assertType(SessionGuard::class, Auth::guard(NumberedGuard::Zero)); + assertType('App\User|null', Auth::guard(NumberedGuard::Zero)->user()); + + // A custom creator wins over the manager's own createTokenDriver(). + assertType(RequestGuard::class, Auth::guard('api')); + assertType(RequestGuard::class, Auth::guard('members')); + + // An Eloquent provider gives its model to any guard driver. + assertType('App\User|null', Auth::guard('web')->user()); + assertType('App\User|null', Auth::guard('members')->user()); + // The stock session and token guards give a database provider's GenericUser. + assertType(GenericUser::class . '|null', Auth::guard('legacy')->user()); + + // Other guards and providers keep the declared user type. + assertType(Authenticatable::class . '|null', Auth::guard('api')->user()); + assertType(Authenticatable::class . '|null', Auth::guard('request')->user()); + assertType(Authenticatable::class . '|null', Auth::guard('remote')->user()); + assertType(Authenticatable::class . '|null', Auth::guard('missing')->user()); + assertType(Authenticatable::class . '|null', Auth::guard('providerless')->user()); + assertType(Authenticatable::class . '|null', Auth::guard()->user()); + assertType(Authenticatable::class . '|null', Auth::user()); + + // A guard macro is only callable through AuthManager when every configured guard registers it. + assertType('int', Auth::guardMacro()); + assertType('int', auth()->guardMacro()); + // A manager the container does not bind forwards to guards of its own. + assertType('*ERROR*', $apiAuthManager->guardMacro()); + assertType('*ERROR*', Auth::sessionGuardMacro()); + assertType('*ERROR*', auth()->sessionGuardMacro()); + + // A custom facade over the auth binding forwards to the current guard too. + assertType('int', CustomAuth::guardMacro()); + assertType('*ERROR*', CustomAuth::sessionGuardMacro()); +} diff --git a/tests/PHPStan/Type/data/auth-untyped-guard.php b/tests/PHPStan/Type/data/auth-untyped-guard.php new file mode 100644 index 0000000000..fa62041b03 --- /dev/null +++ b/tests/PHPStan/Type/data/auth-untyped-guard.php @@ -0,0 +1,18 @@ +logout()); + assertType(SessionGuard::class, Auth::guard('web')); + assertType(SessionGuard::class, Auth::guard('admin')); + assertType(TokenGuard::class, Auth::guard('api')); + // Upstream falls back to StatefulGuard, but only SessionGuard is stateful, so Hypervel + // keeps the declared Guard for a guard it cannot type. + assertType(Guard::class, Auth::guard('passport')); + assertType(Guard::class, Auth::guard('undefined')); + assertType(Guard::class, Auth::guard($guard)); + assertType(Guard::class, Auth::guard($maybeGuard)); + assertType('App\User|null', Auth::guard('web')->user()); + assertType('App\Admin|null', Auth::guard('admin')->user()); + assertType('null', Auth::guard('web')->login($user)); + + assertType(SessionGuard::class, Auth::guard(GuardName::Admin)); + assertType('App\Admin|null', Auth::guard(GuardName::Admin)->user()); + assertType(SessionGuard::class, Auth::guard(UnitGuardName::admin)); + assertType('App\Admin|null', Auth::guard(name: UnitGuardName::admin)->user()); + + assertType(AuthManager::class, auth()); + assertType(AuthManager::class, auth(null)); + assertType(AuthManager::class, auth(guard: null)); + assertType('App\Admin|App\User|null', auth()->user()); + assertType(TokenGuard::class, auth('api')); + assertType(Guard::class, auth('passport')); + assertType('App\Admin|null', auth('admin')->user()); + assertType('App\Admin|null', auth(guard: 'admin')->user()); + assertType('App\Admin|null', Auth('admin')->user()); + assertType(TokenGuard::class, auth()->guard('api')); + assertType('App\Admin|null', auth()->guard(GuardName::Admin)->user()); + + assertType('Hypervel\Auth\SessionGuard|Hypervel\Auth\TokenGuard', Auth::guard($adminOrApi)); + assertType('App\Admin|App\User|null', Auth::guard($adminOrApi)->user()); + + assertType(SessionGuard::class, $sessionGuardFactory->guard('api')); + assertType('App\Admin|null', $adminGuard->user()); + assertType('App\User|null', $userAuthManager->user()); + assertType('App\User|null', UserAuth::user()); + + // A manager the container does not bind selects guards and users in its own way. + assertType(Guard::class, $apiAuthManager->guard('web')); + assertType('Hypervel\Contracts\Auth\Authenticatable|null', $apiAuthManager->guard('web')->user()); + assertType('Hypervel\Contracts\Auth\Authenticatable|null', $apiAuthManager->user()); +} diff --git a/tests/PHPStan/Type/data/benchmark.php b/tests/PHPStan/Type/data/benchmark.php new file mode 100644 index 0000000000..9882f92e74 --- /dev/null +++ b/tests/PHPStan/Type/data/benchmark.php @@ -0,0 +1,16 @@ + 'Hello World')); + // Hypervel: an array's averages are not cast to float, so a whole-millisecond average stays an integer. + assertType('array<0|1, float|int>', Benchmark::measure([fn () => 'Hello World', fn () => 'Hello World'])); + assertType('array<\'test1\'|\'test2\', float|int>', Benchmark::measure(['test1' => fn () => 'Hello World', 'test2' => fn () => 'Hello World'])); + assertType('array<100|\'test\', float|int>', Benchmark::measure(['test' => fn () => 'Hello World', 100 => fn () => 'Hello World'])); +} diff --git a/tests/PHPStan/Type/data/bleeding-edge/closure-usage-inference.php b/tests/PHPStan/Type/data/bleeding-edge/closure-usage-inference.php new file mode 100644 index 0000000000..4f69a18317 --- /dev/null +++ b/tests/PHPStan/Type/data/bleeding-edge/closure-usage-inference.php @@ -0,0 +1,20 @@ +', $query); + }; + User::query()->whereHas('accounts', $constrain); + + $each = function ($user): void { + assertType('App\User', $user); + }; + User::all()->each($each); +} diff --git a/tests/PHPStan/Type/data/bleeding-edge/collection-keys.php b/tests/PHPStan/Type/data/bleeding-edge/collection-keys.php new file mode 100644 index 0000000000..e679f28281 --- /dev/null +++ b/tests/PHPStan/Type/data/bleeding-edge/collection-keys.php @@ -0,0 +1,78 @@ + $list + * @param array $map + * @param array $mixed + */ +function renumberedKeys(array $list, array $map, array $mixed, User $user): void +{ + assertType('Hypervel\Support\Collection<0|1|2, 1|2|3>', collect([1, 2, 3])); + + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->merge([4, 5])); + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->merge($list)); + assertType("Hypervel\Support\Collection<'a'|'b'|'c', 1|2|3>", collect(['a' => 1, 'b' => 2])->merge(['c' => 3])); + assertType('Hypervel\Support\Collection', collect(['a' => 1, 'b' => 2])->merge($map)); + assertType("Hypervel\Support\Collection<'a'|'b'|int, 1|2|4>", collect(['a' => 1, 'b' => 2])->merge([4])); + assertType("Hypervel\Support\Collection", collect([5 => 'x', 9 => 'y'])->merge([1 => 'z'])); + assertType('Hypervel\Support\Collection<(int|string), mixed>', collect($mixed)->merge($mixed)); + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->mergeRecursive([4])); + + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->concat([4, 5])); + assertType("Hypervel\Support\Collection<'a'|'b'|int, 1|2|3>", collect(['a' => 1, 'b' => 2])->concat(['c' => 3])); + + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->flatMap(fn (int $value) => [$value, $value])); + assertType("Hypervel\Support\Collection<'k1'|'k2'|'k3', 1|2|3>", collect([1, 2, 3])->flatMap(fn (int $value) => ['k' . $value => $value])); + + assertType('Hypervel\Support\Collection', collect([1, 2, 3])->multiply(2)); + assertType("Hypervel\Support\Collection", collect([5 => 'x', 9 => 'y'])->shuffle()); + assertType("Hypervel\Support\Collection>", collect([5 => 'x', 9 => 'y'])->split(2)); + assertType("Hypervel\Support\Collection>", collect(['a' => 1, 'b' => 2])->split(2)); + + assertType('Hypervel\Support\LazyCollection', LazyCollection::make([1, 2, 3])->merge([4, 5])); + assertType('Hypervel\Support\LazyCollection', LazyCollection::make([1, 2, 3])->concat([4, 5])); + assertType('Hypervel\Support\LazyCollection', LazyCollection::make([1, 2, 3])->flatMap(fn (int $value) => [$value, $value])); + assertType('Hypervel\Support\LazyCollection', LazyCollection::make([1, 2, 3])->multiply(2)); + + assertType('Hypervel\Database\Eloquent\Collection<0, App\User>', new EloquentCollection([$user])); + assertType('Hypervel\Database\Eloquent\Collection', (new EloquentCollection([$user]))->merge([$user])); + assertType('Hypervel\Database\Eloquent\Collection', (new EloquentCollection([$user]))->concat([$user])); + // Hypervel: the callback returns arrays, so Eloquent's map() and collapse() return a base collection. + assertType('Hypervel\Support\Collection', (new EloquentCollection([$user]))->flatMap(fn (User $user) => [$user, $user])); +} + +function mutations(): void +{ + // REMOVED: push(), add(), prepend() and unshift() value widening. Hypervel keeps Laravel's covariant + // TValue, and PHPStan 2.3.5 does not widen an already inferred covariant TValue to a new value. + + // Hypervel: prepends a value TValue already admits, so only the key widens. + $prependedWithKey = collect(['a' => 1]); + $prependedWithKey->prepend(1, 'b'); + assertType("Hypervel\Support\Collection<'a'|'b', 1>", $prependedWithKey); + + $shifted = collect([5 => 'x', 9 => 'y']); + $shifted->shift(); + assertType("Hypervel\Support\Collection", $shifted); + + $shiftedStringKeys = collect(['a' => 1, 'b' => 2]); + $shiftedStringKeys->shift(); + assertType("Hypervel\Support\Collection<'a'|'b', 1|2>", $shiftedStringKeys); + + $spliced = collect([5 => 'x', 9 => 'y']); + assertType("Hypervel\Support\Collection", $spliced->splice(1)); + assertType("Hypervel\Support\Collection", $spliced); + + $put = collect(['a' => 1, 'b' => 2]); + $put->getOrPut('c', 3); + assertType("Hypervel\Support\Collection<'a'|'b'|'c', 1|2|3>", $put); +} diff --git a/tests/PHPStan/Type/data/bleeding-edge/collection-usage-inference.php b/tests/PHPStan/Type/data/bleeding-edge/collection-usage-inference.php new file mode 100644 index 0000000000..ba9c1e4390 --- /dev/null +++ b/tests/PHPStan/Type/data/bleeding-edge/collection-usage-inference.php @@ -0,0 +1,56 @@ + $ints */ +function takeInts(Collection $ints): void +{ +} + +/** + * @param iterable $users + * @param Collection $names + */ +function emptyCollections(iterable $users, Collection $names, bool $flag): void +{ + $sent = collect(); + assertType('Hypervel\Support\Collection', $sent); + takeInts($sent); + + $sentEmptyArray = collect([]); + assertType('Hypervel\Support\Collection', $sentEmptyArray); + takeInts($sentEmptyArray); + + $pushed = collect(); + foreach ($users as $user) { + $pushed->push($user->name); + } + + assertType('Hypervel\Support\Collection', $pushed); + + $put = collect([]); + $put->put('a', 1); + $put->put('b', 2); + assertType("Hypervel\Support\Collection<'a'|'b', 1|2>", $put); + + assertType('Hypervel\Support\Collection', collect()->merge($names)); + assertType('Hypervel\Support\Collection', $flag ? $names : collect()); +} + +function queries(int $id, string $role): void +{ + // REMOVED: contains(), partition(), some() and reject() value widening. Upstream's stubs redeclare + // TValue invariant; Hypervel keeps Laravel's covariant TValue, which PHPStan 2.3.5 does not widen + // to a queried value. + $ids = collect([1, 2, 3]); + assertType('bool', $ids->contains($id)); + + $roles = collect(['admin', 'editor']); + assertType('bool', $roles->contains($role)); + assertType("Hypervel\Support\Collection<0|1, 'admin'|'editor'>", $roles); +} diff --git a/tests/PHPStan/Type/data/bleeding-edge/eloquent-collection-map.php b/tests/PHPStan/Type/data/bleeding-edge/eloquent-collection-map.php new file mode 100644 index 0000000000..eb92a5812e --- /dev/null +++ b/tests/PHPStan/Type/data/bleeding-edge/eloquent-collection-map.php @@ -0,0 +1,16 @@ + $users */ +function mappedTypesAreInferredFromUsages(EloquentCollection $users): void +{ + $labels = $users->map(fn ($user) => 'unknown'); + $labels->push('other'); + assertType("Hypervel\Support\Collection", $labels); +} diff --git a/tests/PHPStan/Type/data/bug-1346.php b/tests/PHPStan/Type/data/bug-1346.php new file mode 100644 index 0000000000..fd99469c93 --- /dev/null +++ b/tests/PHPStan/Type/data/bug-1346.php @@ -0,0 +1,19 @@ + */ + private Builder $query; + + public function daily() + { + assertType('Hypervel\Database\Eloquent\Builder', $this->query->select()); + } +} diff --git a/tests/PHPStan/Type/data/bug-1565.php b/tests/PHPStan/Type/data/bug-1565.php new file mode 100644 index 0000000000..e2ad027694 --- /dev/null +++ b/tests/PHPStan/Type/data/bug-1565.php @@ -0,0 +1,30 @@ +items); + + $items = array_map($callback, $this->items, $keys); + + return new static(array_combine($keys, $items)); + } + + public function toArray(): array + { + return $this->map(fn($block) => $block->toArray())->values()->all(); + } +} + +function test(TestCollection $collection): void +{ + assertType('mixed', $collection->random()); + assertType('Bug1565\TestCollection', $collection->map(fn ($val) => 'string')); +} diff --git a/tests/PHPStan/Type/data/bug-1718.php b/tests/PHPStan/Type/data/bug-1718.php new file mode 100644 index 0000000000..cecf1538c1 --- /dev/null +++ b/tests/PHPStan/Type/data/bug-1718.php @@ -0,0 +1,31 @@ +user()); + assertType('App\Admin|App\User|null', $formRequest->user()); +} diff --git a/tests/PHPStan/Type/data/bug-1760.php b/tests/PHPStan/Type/data/bug-1760.php new file mode 100644 index 0000000000..6c93f9c1b5 --- /dev/null +++ b/tests/PHPStan/Type/data/bug-1760.php @@ -0,0 +1,29 @@ +>>', collect([new Ok(new Some(42))])); + assertType('Hypervel\Support\Collection>>', collect([42])->map(fn (int $i) => new Ok(new Some($i)))); +} diff --git a/tests/PHPStan/Type/data/bug-1819.php b/tests/PHPStan/Type/data/bug-1819.php new file mode 100644 index 0000000000..af95c57f51 --- /dev/null +++ b/tests/PHPStan/Type/data/bug-1819.php @@ -0,0 +1,12 @@ + 1])); +} diff --git a/tests/PHPStan/Type/data/bug-1830.php b/tests/PHPStan/Type/data/bug-1830.php new file mode 100644 index 0000000000..ee239de0f5 --- /dev/null +++ b/tests/PHPStan/Type/data/bug-1830.php @@ -0,0 +1,19 @@ +id); +} diff --git a/tests/PHPStan/Type/data/bug-1985.php b/tests/PHPStan/Type/data/bug-1985.php new file mode 100644 index 0000000000..64cc9084a2 --- /dev/null +++ b/tests/PHPStan/Type/data/bug-1985.php @@ -0,0 +1,31 @@ +', User::all()->unique()); +} + +class User extends Model +{ + /** @return ModelCollection */ + public function newCollection(array $models = []): ModelCollection + { + return new ModelCollection($models); + } +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class ModelCollection extends Collection +{ +} diff --git a/tests/PHPStan/Type/data/bug-1997.php b/tests/PHPStan/Type/data/bug-1997.php new file mode 100644 index 0000000000..597d651a84 --- /dev/null +++ b/tests/PHPStan/Type/data/bug-1997.php @@ -0,0 +1,24 @@ + */ + public function teams(): BelongsToMany; + + public function getKey(); +} + +interface Team {} + + +function test(User $user): void +{ + // Hypervel: a related type that is not a model forwards to the base builder; PHPStan reports the invalid type argument. + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $user->teams()->whereKey($user->getKey())); +} diff --git a/tests/PHPStan/Type/data/bug-2057.php b/tests/PHPStan/Type/data/bug-2057.php new file mode 100644 index 0000000000..215675771f --- /dev/null +++ b/tests/PHPStan/Type/data/bug-2057.php @@ -0,0 +1,110 @@ + + * @implements TreeLikeCollection + */ +class TreeCollection extends Collection implements TreeLikeCollection +{ +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class FlatCollection extends Collection +{ +} + +/** + * @template TNode of Model + * @template TLabel of \Stringable + */ +interface Labelled +{ +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + * @implements Labelled + */ +class LabelledCollection extends Collection implements Labelled +{ +} + +class Category extends Model +{ + /** + * @param array $models + * @return Collection&TreeLikeCollection + */ + public function newCollection(array $models = []): Collection&TreeLikeCollection + { + return new TreeCollection($models); + } + + /** @return HasMany */ + public function children(): HasMany + { + return $this->hasMany(Category::class, 'parent_id'); + } +} + +class Tag extends Model +{ + /** + * @param array $models + * @return TreeCollection|FlatCollection + */ + public function newCollection(array $models = []): TreeCollection|FlatCollection + { + return new FlatCollection($models); + } +} + +class Node extends Model +{ + /** + * @param array $models + * @return Collection&Labelled + */ + public function newCollection(array $models = []): Collection&Labelled + { + return new LabelledCollection($models); + } +} + +function test(Category $category): void +{ + assertType('Bug2057\TreeLikeCollection&Hypervel\Database\Eloquent\Collection', Category::all()); + assertType('Bug2057\TreeLikeCollection&Hypervel\Database\Eloquent\Collection', Category::query()->get()); + assertType('Bug2057\TreeLikeCollection&Hypervel\Database\Eloquent\Collection', $category->children()->get()); + assertType('Bug2057\TreeLikeCollection&Hypervel\Database\Eloquent\Collection', $category->children); + + assertType('Bug2057\FlatCollection|Bug2057\TreeCollection', Tag::all()); + assertType('Bug2057\FlatCollection|Bug2057\TreeCollection', Tag::query()->get()); + + assertType('Bug2057\Labelled&Hypervel\Database\Eloquent\Collection', Node::all()); + assertType('Bug2057\Labelled&Hypervel\Database\Eloquent\Collection', Node::query()->get()); +} diff --git a/tests/PHPStan/Type/data/bug-2073.php b/tests/PHPStan/Type/data/bug-2073.php new file mode 100644 index 0000000000..126698d527 --- /dev/null +++ b/tests/PHPStan/Type/data/bug-2073.php @@ -0,0 +1,42 @@ +> $collection + * + * @return void + */ +function test(Collection $collection): void +{ + assertType('Bug2073\ModelCollection>', ModelCollection::make($collection)); + assertType('Bug2073\ModelCollection>', ModelCollection::make($collection->all())); +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends EloquentCollection + */ +class ModelCollection extends EloquentCollection {} + +/** + * @template T + */ +class User extends Model +{ + /** + * @param User[] $models + * @return ModelCollection + */ + public function newCollection(array $models = []): ModelCollection + { + return new ModelCollection($models); + } +} diff --git a/tests/PHPStan/Type/data/bug-2111.php b/tests/PHPStan/Type/data/bug-2111.php new file mode 100644 index 0000000000..94432c474d --- /dev/null +++ b/tests/PHPStan/Type/data/bug-2111.php @@ -0,0 +1,31 @@ + */ + public function teams(): BelongsToMany + { + $relation = $this->belongsToMany(Team::class); + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $relation); + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $relation->latest()); + + return $relation; + } + /** @return BelongsToMany */ + public function paidTeams(): BelongsToMany + { + $relation = $this->teams()->whereHas('activeSubscriptions'); + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $relation); + + return $relation; + } +} + +class Team extends Model {} diff --git a/tests/PHPStan/Type/data/bug-2188.php b/tests/PHPStan/Type/data/bug-2188.php new file mode 100644 index 0000000000..4237a95767 --- /dev/null +++ b/tests/PHPStan/Type/data/bug-2188.php @@ -0,0 +1,16 @@ +id); + assertType('string', $ulidModel->id); + assertType('string', $role->id); +} diff --git a/tests/PHPStan/Type/data/bug-2283.php b/tests/PHPStan/Type/data/bug-2283.php new file mode 100644 index 0000000000..fee1923e0b --- /dev/null +++ b/tests/PHPStan/Type/data/bug-2283.php @@ -0,0 +1,36 @@ + + */ + public function user(): BelongsTo + { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $this->belongsTo(User::class) + ->when(true, fn($query) => $query)); + + return $this->belongsTo(User::class) + ->when(true, fn($query) => $query); + } +} + +function test(User $user, bool $condition): void +{ + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->when($condition, fn ($query) => $query->where('active', '1'))); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->unless($condition, fn ($query) => $query->where('active', '1'))); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->tap(fn ($query) => $query->where('active', '1'))); + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $user->roles()->when($condition, fn ($query) => $query->where('name', 'admin'))); + // Hypervel: a false condition returns the builder, which Relation::__call() returns as the relation. + assertType('Hypervel\Database\Eloquent\Relations\HasMany|int<0, max>', $user->accounts()->when($condition, fn ($query) => $query->count())); + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->when($condition, fn ($query) => User::query())); +} diff --git a/tests/PHPStan/Type/data/builder-of-type.php b/tests/PHPStan/Type/data/builder-of-type.php new file mode 100644 index 0000000000..551cef3364 --- /dev/null +++ b/tests/PHPStan/Type/data/builder-of-type.php @@ -0,0 +1,353 @@ + $userBuilder + * @param builder-of<\App\Account> $accountBuilder + * @param builder-of<\App\Team> $teamBuilder + * @param builder-of<\App\User|\App\Team> $union + */ +function test($userBuilder, Builder $accountBuilder, Builder $teamBuilder, Builder $union): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $userBuilder); + assertType('Hypervel\Database\Eloquent\Builder', $accountBuilder); + assertType('App\ChildTeamBuilder', $teamBuilder); + assertType('App\ChildTeamBuilder|Hypervel\Database\Eloquent\Builder', $union); + + assertType('Hypervel\Database\Eloquent\Builder', genericMethod(\App\User::class)); + assertType('Hypervel\Database\Eloquent\Builder', genericMethod(\App\Account::class)); + assertType('App\ChildTeamBuilder', genericMethod(\App\Team::class)); +} + +/** + * @template T of Model + * + * @param class-string $class + * + * @return builder-of + */ +function genericMethod(string $class): Builder +{ + return $class::query(); +} + +class ModelWithStaticBuilder extends Model +{ + /** @return builder-of */ + public function builder(): Builder + { + return $this->newQuery(); + } + + /** @return builder-of<$this> */ + public function instanceBuilder(): Builder + { + return $this->newEloquentBuilder($this->newBaseQueryBuilder())->setModel($this); + } + + public function testStaticBuilder(): void + { + assertType('Hypervel\Database\Eloquent\Builder', $this->builder()); + assertType('Hypervel\Database\Eloquent\Builder<$this(BuilderOfType\ModelWithStaticBuilder)>', $this->instanceBuilder()); + } +} + +class ChildModelWithStaticBuilder extends ModelWithStaticBuilder {} + +function testStaticBuilderOutsideClass(ModelWithStaticBuilder $model, ChildModelWithStaticBuilder $child): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $model->builder()); + assertType('Hypervel\Database\Eloquent\Builder', $child->builder()); +} + +/** @template TValue */ +class GenericModel extends Model {} + +/** + * @param builder-of> $generic + * @param builder-of|\App\Team> $union + * @param builder-of<\App\User&object{extra: string}> $intersection + * @param builder-of<\App\User>|null $nullable + */ +function testModelTypes($generic, $union, $intersection, $nullable): void +{ + assertType('Hypervel\Database\Eloquent\Builder>', $generic); + assertType('App\ChildTeamBuilder|Hypervel\Database\Eloquent\Builder>', $union); + assertType('Hypervel\Database\Eloquent\Builder', $intersection); + assertType('Hypervel\Database\Eloquent\Builder|null', $nullable); +} + +class PostWithBuilderMethod extends \App\Post +{ + /** @return builder-of */ + public function builder(): Builder + { + return $this->newQuery(); + } + + public function testCustomBuilder(): void + { + assertType('App\PostBuilder', $this->builder()); + } +} + +final class FinalPostWithBuilderMethod extends PostWithBuilderMethod {} + +/** @param GenericModel $generic */ +function testInheritedAndGenericBuilders(FinalPostWithBuilderMethod $post, GenericModel $generic): void +{ + assertType('App\PostBuilder', $post->builder()); + assertType('Hypervel\Database\Eloquent\Builder>', $generic->newQuery()); + assertType('Hypervel\Database\Eloquent\Builder>', $generic::query()); +} + +class ModelWithNeverQuery extends Model +{ + public static function query(): never + { + throw new \LogicException(); + } +} + +function testNeverQuery(): void +{ + assertType('never', ModelWithNeverQuery::query()); +} + +/** + * @param builder-of<\App\User, 'accounts'> $accounts + * @param builder-of<\App\User, 'posts'> $posts + * @param builder-of<\App\User, 'posts.comments'> $comments + * @param builder-of<\App\User, 'posts:id'> $postColumns + * @param builder-of<\App\User, 'posts.comments:id,post_id'> $commentColumns + */ +function testRelationshipBuilders($accounts, $posts, $comments, $postColumns, $commentColumns): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $accounts); + assertType('App\PostBuilder', $posts); + assertType('Hypervel\Database\Eloquent\Builder', $comments); + assertType('App\PostBuilder', $postColumns); + assertType('Hypervel\Database\Eloquent\Builder', $commentColumns); +} + +/** + * @param builder-of<\App\BareRelations\Owner> $owner + * @param builder-of<\App\BareRelations\Owner, 'items'> $items + * @param builder-of<\App\BareRelations\Owner, 'items.category'> $category + * @param builder-of<\App\BareRelations\Owner, 'items.category.labels'> $labels + * @param builder-of<\App\BareRelations\Owner, 'missing'> $missing + * @param builder-of<\App\BareRelations\Owner, string> $unknown + */ +function testBareRelationshipBuilders($owner, $items, $category, $labels, $missing, $unknown): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $owner); + assertType('Hypervel\Database\Eloquent\Builder', $items); + assertType('Hypervel\Database\Eloquent\Builder', $category); + assertType('Hypervel\Database\Eloquent\Builder', $labels); + assertType('Hypervel\Database\Eloquent\Builder', $missing); + assertType('Hypervel\Database\Eloquent\Builder', $unknown); +} + +abstract class AbstractSubject extends Model +{ + /** @return HasMany<\App\Account, $this> */ + public function accounts(): HasMany + { + throw new \LogicException(); + } +} + +class ModelWithAbstractRelation extends Model +{ + /** @return HasMany */ + public function subjects(): HasMany + { + throw new \LogicException(); + } + + /** @return MorphTo<\App\Account|AbstractSubject, $this> */ + public function subject(): MorphTo + { + throw new \LogicException(); + } +} + +/** + * @param builder-of $subjects + * @param builder-of $throughAbstract + * @param builder-of $missingOnAbstract + * @param builder-of $missingOnConcrete + * @param builder-of $partOfUnion + * @param builder-of $missingOnUnion + */ +function testAbstractRelatedModels($subjects, $throughAbstract, $missingOnAbstract, $missingOnConcrete, $partOfUnion, $missingOnUnion): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $subjects); + assertType('Hypervel\Database\Eloquent\Builder', $throughAbstract); + assertType('Hypervel\Database\Eloquent\Builder', $missingOnAbstract); + assertType('Hypervel\Database\Eloquent\Builder', $missingOnConcrete); + // Unlike upstream, a model alternative without the relation, here the abstract subject, is unknown. + assertType('Hypervel\Database\Eloquent\Builder', $partOfUnion); + assertType('Hypervel\Database\Eloquent\Builder', $missingOnUnion); +} + +/** + * @param builder-of<\App\User, 'posts'|'accounts'> $relations + * @param builder-of<\App\User, 'posts'|'missing'> $partlyMissing + * @param builder-of<\App\User, 'posts'|'address.addressable.missing'> $partlyUnknown + * @param builder-of<\App\User, 'posts.comments'|'posts.missing'> $nested + * @param builder-of<\App\User|\App\Team, 'posts'> $models + * @param builder-of<\App\User|\App\Team, 'missing'> $missingModels + * @param builder-of<\App\User&object{extra: string}, 'accounts'> $intersection + * @param builder-of<\App\User, 'accounts'>|null $nullable + */ +function testRelationshipUnions($relations, $partlyMissing, $partlyUnknown, $nested, $models, $missingModels, $intersection, $nullable): void +{ + assertType('App\PostBuilder|Hypervel\Database\Eloquent\Builder', $relations); + assertType('Hypervel\Database\Eloquent\Builder', $partlyMissing); + assertType('Hypervel\Database\Eloquent\Builder', $partlyUnknown); + assertType('Hypervel\Database\Eloquent\Builder', $nested); + // Unlike upstream, Team's missing posts relation is unknown. + assertType('Hypervel\Database\Eloquent\Builder', $models); + assertType('Hypervel\Database\Eloquent\Builder', $missingModels); + assertType('Hypervel\Database\Eloquent\Builder', $intersection); + assertType('Hypervel\Database\Eloquent\Builder|null', $nullable); +} + +/** + * @param builder-of<\App\User, 'missing'> $missing + * @param builder-of<\App\User, 'posts.missing'> $nested + * @param builder-of<\App\User, 'missing'|'posts.missing'> $union + * @param builder-of<\App\User, 'getAllCapsName'> $nonRelation + * @param builder-of<\App\User, string> $unknown + * @param builder-of<\App\User, non-empty-string> $nonEmpty + * @param builder-of<\App\User, ''|'posts.'|'.posts'|'posts..comments'> $malformed + * @param builder-of<\App\Team, 'missing'> $custom + */ +function testRelationshipFallbacks($missing, $nested, $union, $nonRelation, $unknown, $nonEmpty, $malformed, $custom): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $missing); + assertType('Hypervel\Database\Eloquent\Builder', $nested); + assertType('Hypervel\Database\Eloquent\Builder', $union); + assertType('Hypervel\Database\Eloquent\Builder', $nonRelation); + assertType('Hypervel\Database\Eloquent\Builder', $unknown); + assertType('Hypervel\Database\Eloquent\Builder', $nonEmpty); + assertType('Hypervel\Database\Eloquent\Builder', $malformed); + assertType('Hypervel\Database\Eloquent\Builder', $custom); +} + +/** + * @template TValue + * @method HasMany<\App\Team, $this> teams() + */ +class ModelWithRelationships extends \App\User +{ + /** @return HasMany, $this> */ + public function genericModels(): HasMany + { + throw new \LogicException(); + } + + /** @return MorphTo<\App\Post|\App\Team, $this> */ + public function subject(): MorphTo + { + return $this->morphTo(); + } + + /** @return HasMany<\App\Account, $this>|null */ + public function nullableRelation(): ?HasMany + { + return null; + } + + /** @return builder-of */ + public function accountBuilder(): Builder + { + return $this->accounts()->getQuery(); + } + + public function testInsideClass(): void + { + assertType('Hypervel\Database\Eloquent\Builder', $this->accountBuilder()); + } +} + +/** + * @param builder-of, 'genericModels'> $generic + * @param builder-of, 'subject'> $polymorphic + * @param builder-of, 'subject.comments'> $nestedUnion + * @param builder-of, 'teams'> $annotation + * @param builder-of, 'nullableRelation'> $nullableRelation + * @param builder-of<\App\Comment, 'commentable'> $broad + * @param builder-of<\App\Comment, 'commentable.comments'> $broadNested + * @param builder-of<\App\User, 'syncableRelation'> $customRelation + * @param ModelWithRelationships $model + */ +function testRelatedModelTypes($generic, $polymorphic, $nestedUnion, $annotation, $nullableRelation, $broad, $broadNested, $customRelation, $model): void +{ + assertType('Hypervel\Database\Eloquent\Builder>', $generic); + assertType('App\ChildTeamBuilder|App\PostBuilder', $polymorphic); + // Unlike upstream, a subject alternative without a comments relation is unknown. + assertType('Hypervel\Database\Eloquent\Builder', $nestedUnion); + assertType('App\ChildTeamBuilder', $annotation); + assertType('Hypervel\Database\Eloquent\Builder', $nullableRelation); + assertType('Hypervel\Database\Eloquent\Builder', $broad); + assertType('Hypervel\Database\Eloquent\Builder', $broadNested); + assertType('Hypervel\Database\Eloquent\Builder', $customRelation); + assertType('Hypervel\Database\Eloquent\Builder', $model->accountBuilder()); +} + +/** + * @template TModel of Model + * @template TRelation of string + * @param class-string $class + * @param TRelation $relation + * @return builder-of + */ +function genericRelationshipBuilder(string $class, string $relation): Builder +{ + throw new \LogicException(); +} + +/** + * @template TRelation of string + * @param builder-of<\App\User, TRelation> $builder + */ +function testUnresolvedRelationshipTemplate($builder): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $builder); +} + +/** @param 'posts'|'missing' $relation */ +function testRelationshipTemplates(string $relation, string $unknown): void +{ + assertType('App\PostBuilder', genericRelationshipBuilder(\App\User::class, 'posts')); + assertType('Hypervel\Database\Eloquent\Builder', genericRelationshipBuilder(\App\User::class, 'posts.comments')); + assertType('Hypervel\Database\Eloquent\Builder', genericRelationshipBuilder(\App\User::class, $relation)); + assertType('Hypervel\Database\Eloquent\Builder', genericRelationshipBuilder(\App\User::class, 'missing')); + assertType('Hypervel\Database\Eloquent\Builder', genericRelationshipBuilder(\App\User::class, $unknown)); +} + +/** + * @param builder-of $unknown + * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *> $relation + * @param builder-of<\App\MissingModel> $missing + */ +function unknownModel($unknown, \Hypervel\Database\Eloquent\Relations\Relation $relation, Builder $query, $missing): void +{ + // Unlike upstream, which resolves to never, an unknown model's builder is unknown too. + assertType('Hypervel\Database\Eloquent\Builder<*>', $unknown); + + // Hypervel: an existence query returns the builder it is given, whatever the relation's model. + assertType('Hypervel\Database\Eloquent\Builder', $relation->getRelationExistenceCountQuery($query, $query)); + + // Unlike upstream, which crashes analysis, a missing class is an unknown model. + assertType('Hypervel\Database\Eloquent\Builder<*>', $missing); +} diff --git a/tests/PHPStan/Type/data/collection-filter.php b/tests/PHPStan/Type/data/collection-filter.php new file mode 100644 index 0000000000..aa1ea37586 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-filter.php @@ -0,0 +1,69 @@ + 1; +} + +/** + * @param EloquentCollection $users + * @param SupportCollection $mixedCollection + */ +function test(User $user, SupportCollection $users, SupportCollection $mixedCollection): void +{ + assertType("Hypervel\Support\Collection<(int|string), mixed~(0|0.0|''|'0'|array{}|false|null)>", collect()->filter()); + + assertType('Hypervel\Support\Collection', collect(['foo', null, '', 'bar', null])->filter()); + + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->filter(function (int $value) { + return $value > 2; + })); + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->filter(fn (int $value) => $value > 2)); + + assertType("Hypervel\Database\Eloquent\Collection", $users->filter(function (User $user): bool { + return ! $user->blocked; + })); + assertType("Hypervel\Database\Eloquent\Collection", $users->filter(fn (User $user) => ! $user->blocked)); + + assertType( + 'Hypervel\Support\Collection', + collect($users->all()) + ->map(function (User $attachment): ?Account { + return convertToAccount($attachment); + }) + ->filter() + ); + + $accounts = $user->accounts()->active()->get(); + assertType('App\AccountCollection', $accounts); + + // Hypervel: calls this file's helper; upstream calls a CollectionStubs function that does not exist. + assertType('App\AccountCollection', $accounts->filter(function ($account) { + return dummyFilter($account); + })); + + $accounts->filter(function ($account) { + return dummyFilter($account); + }) + ->map(function ($account) { + assertType('App\Account', $account); + }); + + assertType("Hypervel\Support\Collection<(int|string), mixed~(0|0.0|''|'0'|array{}|false|null)>", $mixedCollection->pluck('foo')->filter()); +} diff --git a/tests/PHPStan/Type/data/collection-generic-static-methods-l12.php b/tests/PHPStan/Type/data/collection-generic-static-methods-l12.php new file mode 100644 index 0000000000..32aefadff7 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-generic-static-methods-l12.php @@ -0,0 +1,25 @@ + $collection + * @param TransactionCollection $customEloquentCollection + */ +function test( + EloquentCollection $collection, + TransactionCollection $customEloquentCollection, + UserCollection $secondCustomEloquentCollection, +): void { + assertType('Hypervel\Support\Collection, Hypervel\Database\Eloquent\Collection>', $collection->partition('foo')); + assertType('Hypervel\Support\Collection, App\TransactionCollection>', $customEloquentCollection->partition('foo')); + assertType('Hypervel\Support\Collection, App\UserCollection>', $secondCustomEloquentCollection->partition('foo')); +} diff --git a/tests/PHPStan/Type/data/collection-generic-static-methods.php b/tests/PHPStan/Type/data/collection-generic-static-methods.php new file mode 100644 index 0000000000..dfab2d465f --- /dev/null +++ b/tests/PHPStan/Type/data/collection-generic-static-methods.php @@ -0,0 +1,258 @@ + $collection + * @param SupportCollection $items + * @param TransactionCollection $customEloquentCollection + * @param UserCollection $secondCustomEloquentCollection + * @param LazyCollection $lazyCollection + * @param User $user + * @param Enumerable $enumerableIntUsers + * @param Enumerable $enumerableStringUsers + */ +function test( + EloquentCollection $collection, SupportCollection $items, + TransactionCollection $customEloquentCollection, UserCollection $secondCustomEloquentCollection, + LazyCollection $lazyCollection, User $user, + Enumerable $enumerableIntUsers, Enumerable $enumerableStringUsers +): void { + assertType('Hypervel\Database\Eloquent\Collection', EloquentCollection::range(1, 10)); + assertType('Hypervel\Support\LazyCollection', LazyCollection::range(1, 10)); + + assertType('Hypervel\Support\Collection', $collection->collapse()); + assertType('Hypervel\Support\Collection', $items->collapse()); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->crossJoin([1])); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->find($items)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->find([1])); + assertType('App\User|null', $collection->find($user)); + assertType('App\User|null', $collection->find(1)); + assertType('App\User|false', $collection->find(1, false)); + + assertType('App\TransactionCollection', $customEloquentCollection->find($items)); + assertType('App\TransactionCollection', $customEloquentCollection->find([1])); + assertType('App\Transaction|null', $customEloquentCollection->find($user)); + assertType('App\Transaction|null', $customEloquentCollection->find(1)); + assertType('App\Transaction|false', $customEloquentCollection->find(1, false)); + + assertType('App\UserCollection', $secondCustomEloquentCollection->find($items)); + assertType('App\UserCollection', $secondCustomEloquentCollection->find([1])); + assertType('App\User|null', $secondCustomEloquentCollection->find($user)); + assertType('App\User|null', $secondCustomEloquentCollection->find(1)); + assertType('App\User|false', $secondCustomEloquentCollection->find(1, false)); + + assertType('Hypervel\Support\Collection', $collection->flatten()); + assertType('Hypervel\Support\Collection', $items->flatten()); + + // Hypervel: flip() keys are array keys: values that cannot be keys are skipped, and numeric strings become integers. + assertType('Hypervel\Support\Collection<(int|string), int>', $collection->flip()); + assertType('Hypervel\Support\Collection<(int|string), string>', $items->flip()); + + assertType('Hypervel\Database\Eloquent\Collection<(int|string), Hypervel\Database\Eloquent\Collection>', $collection->groupBy('id')); + assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $items->groupBy('id')); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->keyBy(fn (User $user, int $key): string => $user->email)); + + assertType('Hypervel\Support\Collection', $collection->keys()); + assertType('Hypervel\Support\Collection', $items->keys()); + + assertType('Hypervel\Support\Collection<(int|string), mixed>', $collection->pluck(['email'])); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $items->pluck('1')); + + assertType('Hypervel\Support\Collection', $customEloquentCollection->map(fn (Transaction $transaction): int => $transaction->id)); + // Hypervel: $user->id is int here and below, from the incrementing key's implicit cast. + assertType('Hypervel\Support\Collection', $secondCustomEloquentCollection->map(fn (User $user): int => $user->id)); + assertType('Hypervel\Support\Collection', $collection->map(fn (User $user): int => $user->id)); + assertType('Hypervel\Support\Collection', $collection->map(fn (User $user): ?User => $user->id > 5 ? $user : null)); + assertType('Hypervel\Support\Collection', $items->map(fn (int $value, string $key): int => $value)); + + assertType('App\TransactionCollection', $customEloquentCollection->map(fn (Transaction $transaction): Transaction => $transaction)); + assertType('App\UserCollection', $secondCustomEloquentCollection->map(fn (User $user): User => $user)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->map(fn (User $user): User => $user)); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->mapToDictionary(fn (User $u) => [$u->id => $u->id])); + assertType('App\TransactionCollection>', $customEloquentCollection->mapToDictionary(fn (Transaction $t) => ['foo'=> $t->id])); + // Hypervel: known PHPStan limitation. A static<...> return keeps the fixed int keys and User items of a class + // like UserCollection, so results marked with this limitation do not match runtime. This one holds arrays under + // string keys. + assertType('App\UserCollection', $secondCustomEloquentCollection->mapToDictionary(fn (User $t) => ['foo'=> $t->id])); + assertType('Hypervel\Support\Collection>', $items->mapToDictionary(fn (int $v) => ['foo' => $v])); + + assertType('Hypervel\Support\Collection', $customEloquentCollection->mapWithKeys(fn (Transaction $transaction): array => [$transaction->id => 'foo'])); + assertType('Hypervel\Support\Collection', $secondCustomEloquentCollection->mapWithKeys(fn (User $user): array => [$user->id => 'foo'])); + assertType('Hypervel\Support\Collection', $collection->mapWithKeys(fn (User $user): array => [$user->id => $user->id])); + assertType('Hypervel\Support\Collection', $items->mapWithKeys(fn (int $value, string $key): array => ['foo' => $value])); + + assertType('App\TransactionCollection', $customEloquentCollection->mapWithKeys(fn (Transaction $transaction): array => [$transaction->id => $transaction])); + // Hypervel: known PHPStan limitation; this holds string keys. + assertType('App\UserCollection', $secondCustomEloquentCollection->mapWithKeys(fn (User $user): array => [$user->email => $user])); + assertType('Hypervel\Database\Eloquent\Collection', $collection->mapWithKeys(fn (User $user): array => [$user->name => $user])); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->mergeRecursive([2 => 'foo'])); + assertType('App\TransactionCollection', $customEloquentCollection->mergeRecursive([1 => new Transaction()])); + assertType('App\UserCollection', $secondCustomEloquentCollection->mergeRecursive([1 => new User()])); + // Hypervel: mergeRecursive() turns values under a repeated string key into arrays. + assertType('Hypervel\Support\Collection|int>', $items->mergeRecursive(['foo' => 2])); + + assertType('Hypervel\Database\Eloquent\Collection<(int|string), int>', $collection->combine([1])); + assertType('App\TransactionCollection<(int|string), int>', $customEloquentCollection->combine([1])); + // Hypervel: known PHPStan limitation; this holds the given values. + assertType('App\UserCollection', $secondCustomEloquentCollection->combine([1])); + // Hypervel: combine() keys keep the values' type when it is an array-key type. + assertType('Hypervel\Support\Collection', $items->combine(['foo'])); + + assertType('App\User|null', $collection->pop(1)); + assertType('App\TransactionCollection', $customEloquentCollection->pop(2)); + assertType('App\UserCollection', $secondCustomEloquentCollection->pop(2)); + assertType('Hypervel\Support\Collection', $items->pop(3)); + + // Hypervel: BelongsToMany results carry their pivot. + assertType('App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', User::firstOrFail(1)->roles->random()); + assertType('App\RoleCollection', User::firstOrFail(1)->roles->random(1)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->random(1)); + assertType('App\TransactionCollection', $customEloquentCollection->random(2)); + assertType('App\UserCollection', $secondCustomEloquentCollection->random(2)); + assertType('Hypervel\Support\Collection', $items->random(3)); + + assertType('App\User|null', $collection->shift(1)); + assertType('App\TransactionCollection', $customEloquentCollection->shift(2)); + assertType('App\UserCollection', $secondCustomEloquentCollection->shift(2)); + assertType('Hypervel\Support\Collection', $items->shift(3)); + + // Hypervel: sliding() builds its windows through map(), which returns a base collection of collections. + assertType('Hypervel\Support\Collection>', $collection->sliding(1)); + assertType('Hypervel\Support\Collection>', $customEloquentCollection->sliding(2)); + assertType('Hypervel\Support\Collection', $secondCustomEloquentCollection->sliding(2)); + assertType('Hypervel\Support\Collection>', $items->sliding(3)); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->split(1)); + assertType('App\TransactionCollection>', $customEloquentCollection->split(2)); + // Hypervel: known PHPStan limitation; this holds UserCollection chunks. + assertType('App\UserCollection', $secondCustomEloquentCollection->split(2)); + assertType('Hypervel\Support\Collection>', $items->split(3)); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->splitIn(1)); + assertType('App\TransactionCollection>', $customEloquentCollection->splitIn(2)); + // Hypervel: known PHPStan limitation; this holds UserCollection chunks. + assertType('App\UserCollection', $secondCustomEloquentCollection->splitIn(2)); + assertType('Hypervel\Support\Collection>', $items->splitIn(3)); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->chunk(1)); + assertType('App\TransactionCollection>', $customEloquentCollection->chunk(2)); + // Hypervel: known PHPStan limitation; this holds UserCollection chunks. + assertType('App\UserCollection', $secondCustomEloquentCollection->chunk(2)); + assertType('Hypervel\Support\Collection>', $items->chunk(3)); + + assertType('Hypervel\Database\Eloquent\Collection>', $collection->chunkWhile(fn (User $u) => $u->id > 5)); + assertType('App\TransactionCollection>', $customEloquentCollection->chunkWhile(fn (Transaction $t) => $t->id > 5)); + // Hypervel: known PHPStan limitation; this holds UserCollection chunks. + assertType('App\UserCollection', $secondCustomEloquentCollection->chunkWhile(fn (User $t) => $t->id > 5)); + assertType('Hypervel\Support\Collection>', $items->chunkWhile(fn ($v) => $v > 5)); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->values()); + assertType('App\TransactionCollection', $customEloquentCollection->values()); + assertType('App\UserCollection', $secondCustomEloquentCollection->values()); + assertType('Hypervel\Support\Collection', $items->values()); + + assertType('Hypervel\Support\Collection>', $collection->zip([1])); + assertType('Hypervel\Support\Collection>', $customEloquentCollection->zip(['foo'])); + assertType('Hypervel\Support\Collection>', $secondCustomEloquentCollection->zip(['foo'])); + assertType('Hypervel\Support\Collection>', $items->zip(['foo', 'bar'])); + + assertType('Hypervel\Support\Collection, Hypervel\Support\Collection>', $items->partition('foo')); + + assertType('Hypervel\Support\Collection', $collection->pad(10, 10)); + assertType('Hypervel\Support\Collection', $customEloquentCollection->pad(10, 'foo')); + assertType('Hypervel\Support\Collection', $secondCustomEloquentCollection->pad(10, 'foo')); + // Hypervel: pad() keeps string keys, as array_pad() does. + assertType('Hypervel\Support\Collection', $items->pad(1, 'bar')); + + assertType('Hypervel\Support\Collection<(int|string), int>', $collection->countBy('email')); + assertType('Hypervel\Support\Collection<(int|string), int>', $customEloquentCollection->countBy('foo')); + assertType('Hypervel\Support\Collection<(int|string), int>', $secondCustomEloquentCollection->countBy('foo')); + assertType('Hypervel\Support\Collection<(int|string), int>', $items->countBy('bar')); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->concat([new User()])); + assertType('App\TransactionCollection', $customEloquentCollection->concat([new User()])); + assertType('App\UserCollection', $secondCustomEloquentCollection->concat([new User()])); + assertType('Hypervel\Support\Collection', $items->concat([new User()])); + + //////////////////////////// + // EnumeratesValues Trait // + //////////////////////////// + + assertType('Hypervel\Database\Eloquent\Collection', EloquentCollection::make([new User()])); + assertType('App\TransactionCollection', TransactionCollection::make([new Transaction()])); + assertType('Hypervel\Support\Collection', SupportCollection::make([1, 2, 3])); + + assertType('Hypervel\Database\Eloquent\Collection<(int|string), App\User>', EloquentCollection::wrap([new User()])); + assertType('App\TransactionCollection<(int|string), App\Transaction>', TransactionCollection::wrap([new Transaction()])); + assertType('Hypervel\Support\Collection<(int|string), int>', SupportCollection::wrap([1, 2, 3])); + + // Hypervel: times() maps through map(), which can return a base collection, so it declares the base collection. + assertType('Hypervel\Support\Collection', EloquentCollection::times(10, fn ($int) => new User)); + assertType('Hypervel\Support\Collection', TransactionCollection::times(10, fn ($int) => new Transaction)); + assertType('Hypervel\Support\Collection', SupportCollection::times(10, fn ($int) => 5)); + assertType('Hypervel\Support\LazyCollection', LazyCollection::times(10, fn ($int) => 5)); + + // Hypervel: matches the runtime, where map() returns a base collection of collections. + assertType( + 'Hypervel\Support\Collection>', + $collection->mapToGroups(fn (User $user, int $key): array => ['foo' => $user->id]) + ); + + // Hypervel: flatMap() collapses a map() of arrays, which is a base collection. + assertType( + 'Hypervel\Support\Collection', + $collection->flatMap(function (User $user, int $id) { + return [$user->id]; + }) + ); + + assertType( + 'Hypervel\Support\Collection', + $items->flatMap(function (int $int) { + return [$int * 2]; + }) + ); + + assertType( + 'Hypervel\Support\LazyCollection', + $lazyCollection->flatMap(function (User $user, int $id) { + return [$user->id]; + }) + ); + + assertType( + 'Hypervel\Support\LazyCollection', + LazyCollection::times(10, fn ($int) => 5)->flatMap(fn (int $i) => [$i * 2]), + ); + + assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', collect([ + [ + 'id' => 1, + 'type' => 'A', + ], + [ + 'id' => 1, + 'type' => 'B', + ], + ])->groupBy('type')); + + assertType('int|false', $enumerableIntUsers->search(fn(User $user) => $user->id === 1)); + assertType('string|false', $enumerableStringUsers->search(fn(User $user) => $user->id === 1)); +} diff --git a/tests/PHPStan/Type/data/collection-helper.php b/tests/PHPStan/Type/data/collection-helper.php new file mode 100644 index 0000000000..4c07e4bc19 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-helper.php @@ -0,0 +1,38 @@ + $ints + * @param EloquentCollection $users + * @param Traversable $traversable + */ +function test( + EloquentCollection $ints, + EloquentCollection $users, + Traversable $traversable +): void { + assertType('Hypervel\Support\Collection<(int|string), mixed>', collect()); + assertType('Hypervel\Support\Collection<0, 1>', collect(1)); + assertType('Hypervel\Support\Collection<0, \'foo\'>', collect('foo')); + assertType('Hypervel\Support\Collection<0, 3.14>', collect(3.14)); + assertType('Hypervel\Support\Collection<0, true>', collect(true)); + assertType('Hypervel\Support\Collection<(int|string), mixed>', collect([])); + assertType('Hypervel\Support\Collection', collect([1, 2, 3])); + assertType('Hypervel\Support\Collection', collect(['foo', 'bar', 'baz'])); + assertType('Hypervel\Support\Collection', collect([1.0, 2.0, 3.0])); + assertType('Hypervel\Support\Collection', collect([1, 'foo', 1.0])); + assertType("Hypervel\Support\Collection", collect([['a', 'b', 'c']])); + assertType("Hypervel\Support\Collection", collect([['a', 'b', 'c']])->push(array_fill(0, 3, 'x'))); + assertType("Hypervel\Support\Collection", collect([new User, new User])); + assertType("Hypervel\Support\Collection", collect([[new User, new User, new User]])); + assertType('Hypervel\Support\Collection', collect($ints)); + assertType('Hypervel\Support\Collection', collect($users)); + assertType('Hypervel\Support\Collection', collect($traversable)); +} diff --git a/tests/PHPStan/Type/data/collection-key-types.php b/tests/PHPStan/Type/data/collection-key-types.php new file mode 100644 index 0000000000..c741cbf947 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-key-types.php @@ -0,0 +1,100 @@ + $stringKeys + * @param Collection $intKeys + * @param Collection $arrayKeys + * @param LazyCollection $lazy + * @param EloquentCollection $users + * @param array $map + * @param array $keyedUsers + */ +function test( + Collection $stringKeys, + Collection $intKeys, + Collection $arrayKeys, + LazyCollection $lazy, + EloquentCollection $users, + array $map, + array $keyedUsers, +): void { + assertType('Hypervel\Support\Collection', $stringKeys->merge($map)); + assertType('Hypervel\Support\Collection', $intKeys->merge([1])); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $arrayKeys->merge([1])); + // Hypervel: mergeRecursive() turns values under a repeated string key into arrays. + assertType('Hypervel\Support\Collection|int>', $stringKeys->mergeRecursive($map)); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $arrayKeys->mergeRecursive([1])); + assertType('Hypervel\Support\LazyCollection', $lazy->merge($map)); + assertType('Hypervel\Database\Eloquent\Collection', $users->merge($keyedUsers)); + + assertType('Hypervel\Support\Collection', $stringKeys->concat($map)); + assertType('Hypervel\Support\Collection', $intKeys->concat($map)); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $arrayKeys->concat($map)); + // Hypervel: the lazy concat() calls values(), as in Laravel, so its keys are always integers. + assertType('Hypervel\Support\LazyCollection', $lazy->concat($map)); + + assertType('Hypervel\Support\Collection', $intKeys->flatMap(fn (int $value) => $map)); + assertType('Hypervel\Support\Collection', $stringKeys->flatMap(fn (int $value) => [$value])); + assertType('Hypervel\Support\LazyCollection', $lazy->flatMap(fn (int $value) => [$value])); + + assertType('Hypervel\Support\Collection', $stringKeys->shuffle()); + assertType('Hypervel\Support\Collection', $stringKeys->multiply(2)); + assertType('Hypervel\Support\LazyCollection', $lazy->shuffle()); + assertType('Hypervel\Support\LazyCollection', $lazy->multiply(2)); + + assertType('Hypervel\Support\Collection>', $stringKeys->split(2)); + assertType('Hypervel\Support\Collection>', $arrayKeys->split(2)); + assertType('Hypervel\Support\LazyCollection>', $lazy->split(2)); +} + +/** + * @param Collection $appended + * @param Collection $prepended + * @param Collection $pushedArrayKeys + * @param Collection $shifted + * @param Collection $shiftedArrayKeys + * @param Collection $spliced + * @param Collection $put + */ +function mutations( + Collection $appended, + Collection $prepended, + Collection $pushedArrayKeys, + Collection $shifted, + Collection $shiftedArrayKeys, + Collection $spliced, + Collection $put, +): void +{ + $appended->push(1); + assertType('Hypervel\Support\Collection', $appended); + + $prepended->prepend(1, 'key'); + assertType('Hypervel\Support\Collection', $prepended); + $prepended->prepend(1); + assertType('Hypervel\Support\Collection', $prepended); + + $pushedArrayKeys->add(1); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $pushedArrayKeys); + + $shifted->shift(); + assertType('Hypervel\Support\Collection', $shifted); + + $shiftedArrayKeys->shift(); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $shiftedArrayKeys); + + assertType('Hypervel\Support\Collection', $spliced->splice(1)); + assertType('Hypervel\Support\Collection', $spliced); + + assertType('int|null', $put->getOrPut(1, null)); + assertType('Hypervel\Support\Collection<1|string, int|null>', $put); +} diff --git a/tests/PHPStan/Type/data/collection-make-static.php b/tests/PHPStan/Type/data/collection-make-static.php new file mode 100644 index 0000000000..80bace3bbd --- /dev/null +++ b/tests/PHPStan/Type/data/collection-make-static.php @@ -0,0 +1,30 @@ + $users + * @param Traversable $traversable + */ +function test(EloquentCollection $users, Traversable $traversable): void +{ + // assertType('Hypervel\Support\Collection', SupportCollection::make([])); + assertType('Hypervel\Support\Collection', SupportCollection::make([1, 2, 3])); + assertType('Hypervel\Support\Collection', SupportCollection::make(['foo', 'bar', 'baz'])); + assertType('Hypervel\Support\Collection', SupportCollection::make([1.0, 2.0, 3.0])); + assertType('Hypervel\Support\Collection', SupportCollection::make([1, 'foo', 1.0])); + + assertType('Hypervel\Support\Collection', SupportCollection::make($users)); + + /** @var EloquentCollection $users */ + assertType('Hypervel\Support\Collection', SupportCollection::make($users)); + + assertType('Hypervel\Support\Collection', SupportCollection::make($traversable)); +} diff --git a/tests/PHPStan/Type/data/collection-narrowing-boundaries.php b/tests/PHPStan/Type/data/collection-narrowing-boundaries.php new file mode 100644 index 0000000000..07b83fcd4b --- /dev/null +++ b/tests/PHPStan/Type/data/collection-narrowing-boundaries.php @@ -0,0 +1,108 @@ + $values + */ +function templateValues(Collection $values): void +{ + assertType('Hypervel\Support\Collection', $values->filter()); + assertType('Hypervel\Support\Collection', $values->reject(fn ($value) => $value === null)); + assertType('Hypervel\Support\Collection', $values->whereNotNull()); +} + +/** + * Upstream rebuilt a union of collections as its first collection class. + * + * @param Collection|LazyCollection $values + */ +function unionOfCollections(Collection|LazyCollection $values): void +{ + assertType('Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $values->filter()); + assertType('Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $values->whereNotNull()); +} + +/** + * Upstream rebuilt a collection with one template as if it took a key and a value. + * + * @param OnlyValueGenericCollection $models + */ +function oneTemplate(OnlyValueGenericCollection $models): void +{ + assertType('App\OnlyValueGenericCollection', $models->filter()); + assertType('App\OnlyValueGenericCollection', $models->whereNotNull()); +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class KeepingCollection extends Collection +{ + public function filter(?callable $callback = null): static + { + return $this; + } +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class KeepingWhereNotNullCollection extends Collection +{ + public function whereNotNull(?string $key = null): static + { + return $this; + } +} + +/** + * A collection that overrides a narrowing method, or one that it runs, may keep other values. + * + * @param KeepingCollection $keeping + * @param KeepingWhereNotNullCollection $keepingWhereNotNull + */ +function customImplementations(KeepingCollection $keeping, KeepingWhereNotNullCollection $keepingWhereNotNull): void +{ + assertType('CollectionNarrowingBoundaries\KeepingCollection', $keeping->filter()); + assertType('CollectionNarrowingBoundaries\KeepingCollection', $keeping->reject(fn ($value) => $value === null)); + assertType('CollectionNarrowingBoundaries\KeepingCollection', $keeping->where(fn ($value) => $value !== null)); + assertType('CollectionNarrowingBoundaries\KeepingCollection', $keeping->whereNotNull()); + assertType('CollectionNarrowingBoundaries\KeepingWhereNotNullCollection', $keepingWhereNotNull->whereNotNull()); + assertType('CollectionNarrowingBoundaries\KeepingWhereNotNullCollection', $keepingWhereNotNull->filter(fn ($value) => $value !== null)); +} + +/** + * Hypervel: callbacks receive the key as well as the value, and only callables that are not strings narrow reject() and where(). + * + * @param Collection $values + * @param LazyCollection $lazyValues + */ +function callbackArguments(Collection $values, LazyCollection $lazyValues): void +{ + assertType('Hypervel\Support\Collection', $values->filter(fn ($value, $key) => is_string($key))); + assertType('Hypervel\Support\LazyCollection', $lazyValues->reject(fn ($value, $key) => is_string($key))); + assertType('Hypervel\Support\Collection|int<1, max>>', $values->filter(null)); + assertType('Hypervel\Support\Collection', $values->where(function ($value) { + return $value !== null; + })); + assertType('Hypervel\Support\Collection', $values->where('id', 1)); + assertType('Hypervel\Support\Collection', $values->reject(null)); +} diff --git a/tests/PHPStan/Type/data/collection-nullsafe-chain.php b/tests/PHPStan/Type/data/collection-nullsafe-chain.php new file mode 100644 index 0000000000..ebe39d7aa4 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-nullsafe-chain.php @@ -0,0 +1,28 @@ + $users */ + public function __construct(public Collection $users) + { + } +} + +function test(?Holder $holder): void +{ + assertType('Hypervel\Support\Collection|null', $holder?->users->filter(fn (?User $user) => $user instanceof User)); + assertType('Hypervel\Support\Collection|null', $holder?->users->filter(function (?User $user) { + return $user instanceof User; + })); + assertType('Hypervel\Support\Collection|null', $holder?->users->filter()); + assertType('Hypervel\Support\Collection|null', $holder?->users->reject(fn (?User $user) => $user === null)); + assertType('Hypervel\Support\Collection|null', $holder?->users->whereNotNull()); + assertType('Hypervel\Support\Collection|null', $holder?->users->values()->filter(fn (?User $user) => $user instanceof User)); +} diff --git a/tests/PHPStan/Type/data/collection-of-type.php b/tests/PHPStan/Type/data/collection-of-type.php new file mode 100644 index 0000000000..37068d12fc --- /dev/null +++ b/tests/PHPStan/Type/data/collection-of-type.php @@ -0,0 +1,69 @@ + $param + * @param collection-of<\App\Role> $param2 + * @param collection-of<\App\User|\App\Role> $union + */ +function test($param, Collection $param2, Collection $union): void +{ + assertType('Hypervel\Database\Eloquent\Collection<(int|string), App\User>', $param); + assertType('App\RoleCollection<(int|string), App\Role>', $param2); + assertType('App\RoleCollection<(int|string), App\Role>|Hypervel\Database\Eloquent\Collection<(int|string), App\User>', $union); + + assertType('Hypervel\Database\Eloquent\Collection<(int|string), App\User>', genericMethod(\App\User::class)); + assertType('App\RoleCollection<(int|string), App\Role>', genericMethod(\App\Role::class)); +} + +/** + * @template T of Model + * + * @param class-string $class + * + * @return collection-of + */ +function genericMethod(string $class): Collection +{ + return (new $class)->newCollection(); +} + +/** + * @param collection-of $unknown + * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *> $relation + * @param collection-of $constantCollected + */ +function unknownModels($unknown, \Hypervel\Database\Eloquent\Relations\Relation $relation, $constantCollected): void +{ + // Unlike upstream, which resolves to never, an unknown model has the base model's collection. + assertType('Hypervel\Database\Eloquent\Collection<(int|string), Hypervel\Database\Eloquent\Model>', $unknown); + assertType('Hypervel\Database\Eloquent\Collection', $relation->get()); + + // Unlike upstream, a class constant's value names the collection, as instantiating the attribute reads it. + assertType('CollectionOfType\ConstantCollection<(int|string), CollectionOfType\ConstantCollectedModel>', $constantCollected); +} + +class CollectionNames +{ + public const string COLLECTION = ConstantCollection::class; +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class ConstantCollection extends Collection +{ +} + +#[\Hypervel\Database\Eloquent\Attributes\CollectedBy(CollectionNames::COLLECTION)] +class ConstantCollectedModel extends Model +{ +} diff --git a/tests/PHPStan/Type/data/collection-reject.php b/tests/PHPStan/Type/data/collection-reject.php new file mode 100644 index 0000000000..898b5c1984 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-reject.php @@ -0,0 +1,64 @@ + 1; +} + +/** @param EloquentCollection $users */ +function test(User $user, SupportCollection $users): void +{ + assertType("Hypervel\Support\Collection<(int|string), 0|0.0|''|'0'|array{}|false|null>", collect()->reject()); + + assertType("Hypervel\Support\Collection", collect(['foo', null, '', 'bar', null])->reject()); + + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->reject(function (int $value) { + return $value > 2; + })); + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->reject(fn (int $value) => $value > 2)); + + assertType("Hypervel\Database\Eloquent\Collection", $users->reject(function (User $user): bool { + return ! $user->blocked; + })); + assertType("Hypervel\Database\Eloquent\Collection", $users->reject(fn (User $user) => ! $user->blocked)); + + assertType( + 'Hypervel\Support\Collection', + collect($users->all()) + ->map(function (User $attachment): ?Account { + return convertToAccount($attachment); + }) + ->reject() + ); + + $accounts = $user->accounts()->active()->get(); + assertType('App\AccountCollection', $accounts); + + // Hypervel: calls this file's helper; upstream calls a CollectionStubs function that does not exist. + assertType('App\AccountCollection', $accounts->reject(function ($account) { + return dummyReject($account); + })); + + $accounts->reject(function ($account) { + return dummyReject($account); + }) + ->map(function ($account) { + assertType('App\Account', $account); + }); +} diff --git a/tests/PHPStan/Type/data/collection-stubs-l12-20.php b/tests/PHPStan/Type/data/collection-stubs-l12-20.php new file mode 100644 index 0000000000..301b2475ed --- /dev/null +++ b/tests/PHPStan/Type/data/collection-stubs-l12-20.php @@ -0,0 +1,14 @@ +', User::get()->pluck(fn (User $user) => "$user->id - $user->email", 'id')); + assertType('Hypervel\Support\Collection', User::get()->pluck('id', fn (User $user) => "$user->id - $user->email")); + // Unlike upstream, the value is the name column: User::email() is an accessor Eloquent applies, so email reads as its untyped callback. + assertType('Hypervel\Support\Collection', User::get()->pluck(fn (User $user) => $user->name, fn (User $user) => "$user->id - $user->email")); +} diff --git a/tests/PHPStan/Type/data/collection-stubs.php b/tests/PHPStan/Type/data/collection-stubs.php new file mode 100644 index 0000000000..518580e3e3 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-stubs.php @@ -0,0 +1,122 @@ + $collection + * @param SupportCollection $items + * @param SupportCollection $collectionOfUsers + */ +function test( + EloquentCollection $collection, + SupportCollection $items, + SupportCollection $collectionOfUsers, + User $user, +): void { + assertType('Hypervel\Database\Eloquent\Collection', User::all()->each(function (User $user, int $key): void { + })); + + assertType('Hypervel\Support\Collection', $items->each(function (): bool { + return false; + })); + + assertType('Hypervel\Support\Collection', $items->map(function (int $item): string { + return (string) $item; + })); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->find($items)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->find([1])); + assertType('App\User|null', $collection->find($user)); + assertType('App\User|null', $collection->find(1)); + assertType('App\User|false', $collection->find(1, false)); + + assertType('Hypervel\Support\Collection<(int|string), mixed>', $collection->pluck('id')); + + // Hypervel: Eloquent map() returns a base collection unless every result is a model, so mapInto() declares + // either. flatMap() always returns a base collection, because the Eloquent collapse() calls toBase(). + assertType('Hypervel\Support\Collection', User::all()->mapInto(User::class)); + assertType('Hypervel\Support\Collection', $collection->flatMap(function (User $user, int $id): array { + return [$user]; + })); + + assertType('Hypervel\Database\Eloquent\Collection', $collection->tap(function ($collection): void { + })); + + $foo = collect([ + [ + 'id' => 1, + 'type' => 'A', + ], + [ + 'id' => 1, + 'type' => 'B', + ], + ]); + + $foo + ->groupBy('type') + ->map(function ($grouped, $groupKey): array { + assertType('(int|string)', $groupKey); + }); + + assertType('App\User|null', $collection->first()); + assertType('App\User|false', $collection->first(null, false)); + assertType('App\User|null', $collection->first(function ($user) { + assertType('App\User', $user); + + return $user->id > 1; + })); + assertType('App\User|false', $collection->first(function (User $user) { + assertType('App\User', $user); + + return $user->id > 1; + }, function () { + return false; + })); + + assertType('App\User|null', $collection->firstWhere('blocked')); + assertType('App\User|null', $collection->firstWhere('blocked', true)); + assertType('App\User|null', $collection->firstWhere('blocked', '=', true)); + + assertType('App\User|null', $collection->last()); + assertType('App\User|false', $collection->last(null, false)); + assertType('App\User|null', $collection->last(function (User $user) { + return $user->id > 1; + })); + assertType('App\User|false', $collection->last(function (User $user) { + return $user->id > 1; + }, function () { + return false; + })); + + assertType('App\User|null', $collection->get(1)); + assertType('App\User', $collection->get(1, new User())); + + assertType('App\User|null', $collection->pull(1)); + assertType('App\User', $collection->pull(1, new User())); + assertType('Hypervel\Database\Eloquent\Collection', User::all()->filter()); + + assertType('App\User', $collection->random()); + assertType('Hypervel\Database\Eloquent\Collection', $collection->random(5)); + + assertType('App\User', $collectionOfUsers->random()); + assertType('Hypervel\Support\Collection', $collectionOfUsers->random(5)); + + assertType('App\User|null', $collection->pop()); + assertType('Hypervel\Database\Eloquent\Collection', $collection->pop(5)); + + assertType('App\User|null', $collectionOfUsers->pop()); + assertType('Hypervel\Support\Collection', $collectionOfUsers->pop(5)); + + assertType('App\User|null', $collection->shift()); + assertType('Hypervel\Database\Eloquent\Collection', $collection->shift(5)); + + assertType('App\User|null', $collectionOfUsers->shift()); + assertType('Hypervel\Support\Collection', $collectionOfUsers->shift(5)); +} diff --git a/tests/PHPStan/Type/data/collection-where-not-null.php b/tests/PHPStan/Type/data/collection-where-not-null.php new file mode 100644 index 0000000000..aa18dc9f7a --- /dev/null +++ b/tests/PHPStan/Type/data/collection-where-not-null.php @@ -0,0 +1,22 @@ + $foo */ +function test(EloquentCollection $foo): void +{ + assertType('Hypervel\Support\Collection', collect([1, 2, null, '', 'hello'])->whereNotNull()); + assertType('Hypervel\Support\Collection', collect([1, 2, null, '', 'hello'])->whereNotNull()); + assertType('Hypervel\Support\Collection', collect([1, 2, null, '', 'hello'])->whereNotNull(null)); + assertType( + 'Hypervel\Support\Collection', + collect([new \App\User, new \stdClass, ['id' => 'foo'], 'foo', true, 22])->whereNotNull('id') + ); + assertType("Hypervel\Database\Eloquent\Collection", $foo->whereNotNull('blocked')); + assertType("Hypervel\Database\Eloquent\Collection", $foo->whereNotNull('blocked')); + assertType("Hypervel\Database\Eloquent\Collection", $foo->whereNotNull()); +} diff --git a/tests/PHPStan/Type/data/collection-where.php b/tests/PHPStan/Type/data/collection-where.php new file mode 100644 index 0000000000..5a5103e083 --- /dev/null +++ b/tests/PHPStan/Type/data/collection-where.php @@ -0,0 +1,65 @@ + 1; +} + +/** + * @param EloquentCollection $users + * @param SupportCollection $mixedCollection + */ +function test(User $user, SupportCollection $users, SupportCollection $mixedCollection): void +{ + assertType("Hypervel\Support\Collection<(int|string), mixed>", collect()->where('foo', '<>', 1)); + + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->where(function (int $value) { + return $value > 2; + })); + assertType('Hypervel\Support\Collection>', collect([1, 2, 3, 4, 5, 6])->where(fn (int $value) => $value > 2)); + + assertType("Hypervel\Database\Eloquent\Collection", $users->where(function (User $user): bool { + return ! $user->blocked; + })); + assertType("Hypervel\Database\Eloquent\Collection", $users->where(fn (User $user) => ! $user->blocked)); + + assertType( + 'Hypervel\Support\Collection', + collect($users->all()) + ->map(function (User $attachment): ?Account { + return convertToAccount($attachment); + }) + ->where(fn($user) => $user) + ); + + $accounts = $user->accounts()->active()->get(); + assertType('App\AccountCollection', $accounts); + + // Hypervel: calls this file's helper; upstream calls a CollectionStubs function that does not exist. + assertType('App\AccountCollection', $accounts->where(function ($account) { + return dummyFilter($account); + })); + + $accounts->where(function ($account) { + return dummyFilter($account); + }) + ->map(function ($account) { + assertType('App\Account', $account); + }); +} diff --git a/tests/PHPStan/Type/data/concurrency-drivers.php b/tests/PHPStan/Type/data/concurrency-drivers.php new file mode 100644 index 0000000000..4784370fc5 --- /dev/null +++ b/tests/PHPStan/Type/data/concurrency-drivers.php @@ -0,0 +1,36 @@ +driver('sync')); + + // Drivers the analysis cannot type keep the declaration. + assertType('Hypervel\Contracts\Concurrency\Driver', Concurrency::driver($name)); + assertType('Hypervel\Contracts\Concurrency\Driver', Concurrency::driver('missing')); + assertType('Hypervel\Contracts\Concurrency\Driver', Concurrency::driver('0')); + assertType('Hypervel\Contracts\Concurrency\Driver', $tenantManager->driver('sync')); +} diff --git a/tests/PHPStan/Type/data/conditionable.php b/tests/PHPStan/Type/data/conditionable.php new file mode 100644 index 0000000000..4e15fff692 --- /dev/null +++ b/tests/PHPStan/Type/data/conditionable.php @@ -0,0 +1,203 @@ + $query */ +function test(Builder $query): void +{ + assertType('ConditionableStubs\Foo', (new Foo())->when(true, function (Foo $foo) { + // do nothing + })); + + assertType('ConditionableStubs\Foo', (new Foo())->when(true, function (Foo $foo) { + return null; + })); + + // Hypervel: a false condition returns the instance, so it stays in the type. + assertType('ConditionableStubs\Foo|int<0, max>', (new Foo())->when(true, function (Foo $foo): int { + return rand(); + })); + + // Test to make sure the callback has a non-null value. + (new Foo())->when(User::first(), function (Foo $foo, $user): void { + assertType(User::class, $user); + }); + + assertType('ConditionableStubs\Foo', (new Foo())->unless(true, function (Foo $foo) { + // do nothing + })); + + assertType('ConditionableStubs\Foo', (new Foo())->unless(true, function (Foo $foo) { + return null; + })); + + // Hypervel: a true condition returns the instance, so it stays in the type. + assertType('ConditionableStubs\Foo|int<0, max>', (new Foo())->unless(true, function (Foo $foo): int { + return rand(); + })); + + assertType('Hypervel\Database\Eloquent\Builder', $query->when(true, static function (Builder $query): Builder { + /** @phpstan-var Builder $query */ + return $query->whereNull('name'); + })); +} + +class Logger +{ + use Conditionable; + + public bool $toggle = false; + + public function has(string $key): bool + { + return true; + } + + public function log(string $message): static + { + return $this; + } + + private function flush(): void + { + } +} + +/** + * Hypervel: without a callback, when() and unless() return a proxy. Given a value, it takes the + * condition from the value; otherwise it takes the condition from the next property or method. + * + * @param Collection $numbers + */ +function higherOrder(Logger $logger, Collection $numbers, ?string $maybe, callable $callback): void +{ + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->when()); + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->unless()); + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->when($maybe)); + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->unless(true)); + + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->when()->has('init')); + assertType('ConditionableStubs\Logger', $logger->when()->has('init')->log('three')); + assertType('ConditionableStubs\Logger', $logger->unless()->toggle->log('five')); + assertType('bool|ConditionableStubs\Logger', $logger->when(true)->toggle); + assertType('Hypervel\Support\Collection', $numbers->when()->isNotEmpty()->map(fn ($number) => $number + 1)); + + // The proxy runs outside the target's class, so it reaches only public members. + assertType('*ERROR*', $logger->when(true)->flush()); + + // A callback argument, even null, makes the call return the instance or the default's result. + assertType('ConditionableStubs\Logger', $logger->when(false, null)); + assertType("'fallback'|ConditionableStubs\Logger", $logger->when(false, null, fn () => 'fallback')); + assertType("'fallback'|ConditionableStubs\Logger", $logger->unless(true, null, fn () => 'fallback')); + assertType("'default'|ConditionableStubs\Logger", $logger->when(default: fn () => 'default')); + assertType('Hypervel\Support\HigherOrderWhenProxy', $logger->when(value: true)); + + // Unpacked arguments keep the declaration, which a default rules the proxy out of. + assertType("'fallback'|ConditionableStubs\Logger", $logger->when(...[false, null, fn () => 'fallback'])); + assertType("'fallback'|ConditionableStubs\Logger", $logger->unless(...[true, null, fn () => 'fallback'])); + + // What the callback or default returns is kept, even a proxy or mixed. + $captured = $logger->when(); + assertType('ConditionableStubs\Logger|Hypervel\Support\HigherOrderWhenProxy', $logger->when(true, fn () => $captured)); + assertType('ConditionableStubs\Logger|Hypervel\Support\HigherOrderWhenProxy', $logger->when(false, null, fn () => $captured)); + assertType('mixed', $logger->when(true, $callback)); + + // Setting the condition changes the proxy itself. + $proxy = new HigherOrderWhenProxy($logger); + $proxy->condition(true); + assertType('Hypervel\Support\HigherOrderWhenProxy', $proxy); +} + +/** + * Hypervel: a model runs the call on a new builder and returns the builder's result, while a relation + * runs it on its own builder and returns itself in place of that builder. + * + * @template TModel of User + * + * @param class-string $class + * @param callable(Builder): Builder $callback + * @param Builder $other + */ +function forwarded(User $user, bool $condition, string $class, callable $callback, Builder $other): void +{ + assertType('Hypervel\Support\HigherOrderWhenProxy, true>', $user->when($condition)); + assertType('Hypervel\Support\HigherOrderWhenProxy, true>', User::when($condition)); + assertType('Hypervel\Support\HigherOrderWhenProxy, false>', User::unless()); + assertType('Hypervel\Database\Eloquent\Collection', User::when($condition)->where('name', 'x')->get()); + assertType('Hypervel\Database\Eloquent\Builder|int<0, max>', User::when($condition, fn ($query) => $query->count())); + assertType('Hypervel\Database\Eloquent\Builder', $user->unless($condition, fn ($query) => $query->where('name', 'x'))); + assertType('Hypervel\Support\HigherOrderWhenProxy, true>', $class::when($condition)); + + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $user->posts()->unless($condition, function ($query) { + assertType('$this(App\PostBuilder)', $query); + + return $query->where('name', 'x'); + })); + // A different builder of the same class is not the relation's own, so it is returned as it is. + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->when($condition, fn ($query) => clone $query)); + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->when($condition, fn ($query) => Account::query())); + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->when($condition, $callback)); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->when($condition, fn (Builder $query) => $query->where('active', '1')->latest())); + // The value passed to the callback is not the relation's own builder either. + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->when($other, function ($query, $value) { + assertType('Hypervel\Database\Eloquent\Builder', $value); + + return $value; + })); + assertType('Hypervel\Support\HigherOrderWhenProxy, true>', $user->posts()->when($condition)); + assertType('Hypervel\Support\HigherOrderWhenProxy, false>', $user->accounts()->unless()); +} + +class CustomWhen +{ + /** @return HigherOrderWhenProxy */ + public function when(mixed $value = null): HigherOrderWhenProxy + { + return (new HigherOrderWhenProxy(new Logger))->condition(true); + } +} + +function customWhen(CustomWhen $custom): void +{ + // A method that is not Conditionable's keeps its own declaration. + assertType('Hypervel\Support\HigherOrderWhenProxy', $custom->when()); +} + +class Formatter +{ + public function format(Logger $logger): void + { + // A callback's own $this stays the callback's class. + assertType('$this(ConditionableStubs\Formatter)|ConditionableStubs\Logger', $logger->when(true, fn () => $this)); + } +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class Numbers extends Collection +{ + public static function strings(): void + { + // The proxy keeps the called object's types, and its members' static types mean that object. + assertType('Hypervel\Support\HigherOrderWhenProxy), true>', static::range(1, 3)->unless(false)); + assertType('ConditionableStubs\Numbers|ConditionableStubs\Numbers', static::range(1, 3)->unless(false)->map(fn ($number) => (string) $number)); + } +} diff --git a/tests/PHPStan/Type/data/config-check-model-properties.neon b/tests/PHPStan/Type/data/config-check-model-properties.neon new file mode 100644 index 0000000000..12e4137aa2 --- /dev/null +++ b/tests/PHPStan/Type/data/config-check-model-properties.neon @@ -0,0 +1,4 @@ +includes: + - ../../phpstan-tests.neon +parameters: + checkModelProperties: true diff --git a/tests/PHPStan/Type/data/config-facade-collection-method.php b/tests/PHPStan/Type/data/config-facade-collection-method.php new file mode 100644 index 0000000000..0ce1a28c88 --- /dev/null +++ b/tests/PHPStan/Type/data/config-facade-collection-method.php @@ -0,0 +1,15 @@ +", Config::collection('auth.defaults')); + + assertType('Hypervel\Support\Collection<0|1|2, 1|2|3>', Config::collection('test.bar')); + assertType('*ERROR*', Config::collection('test.foo')); +} diff --git a/tests/PHPStan/Type/data/config-file-dependency.php b/tests/PHPStan/Type/data/config-file-dependency.php new file mode 100644 index 0000000000..71305d6ddd --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-dependency.php @@ -0,0 +1,11 @@ +get('test.foo'); + config('missing.foo'); + config($key); +} diff --git a/tests/PHPStan/Type/data/config-file-resolution.neon b/tests/PHPStan/Type/data/config-file-resolution.neon new file mode 100644 index 0000000000..73fd85d99c --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution.neon @@ -0,0 +1,10 @@ +includes: + - ../../phpstan-tests.neon +parameters: + # Hypervel's own configurations turn this off, and config file @return types still apply. + treatPhpDocTypesAsCertain: false + checkConfigTypes: true + configDirectories: + - %rootDir%/../../../tests/PHPStan/Type/data/config-file-resolution/config + - %rootDir%/../../../tests/PHPStan/Type/data/config-file-resolution/modules/*/config + - '%rootDir%/../../../tests/PHPStan/Type/data/config-file-resolution/literal[1]/config' diff --git a/tests/PHPStan/Type/data/config-file-resolution.php b/tests/PHPStan/Type/data/config-file-resolution.php new file mode 100644 index 0000000000..0a69df97fa --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution.php @@ -0,0 +1,43 @@ + 'from app.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/nested.php b/tests/PHPStan/Type/data/config-file-resolution/config/nested.php new file mode 100644 index 0000000000..bf7fa2f173 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/nested.php @@ -0,0 +1,7 @@ + 'from nested.php', + 'rcnest' => ['v' => 'from nested.php, replaced by nested/rcnest.php'], + 'deep' => ['v' => 'from nested.php'], +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/nested/deep/leaf.php b/tests/PHPStan/Type/data/config-file-resolution/config/nested/deep/leaf.php new file mode 100644 index 0000000000..ad0d38ada7 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/nested/deep/leaf.php @@ -0,0 +1,5 @@ + 'from nested/deep/leaf.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/nested/rcnest.php b/tests/PHPStan/Type/data/config-file-resolution/config/nested/rcnest.php new file mode 100644 index 0000000000..050b5955f8 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/nested/rcnest.php @@ -0,0 +1,5 @@ + 'from nested/rcnest.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/nested/typed.php b/tests/PHPStan/Type/data/config-file-resolution/config/nested/typed.php new file mode 100644 index 0000000000..389411fac2 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/nested/typed.php @@ -0,0 +1,6 @@ + 1, +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/rcdup.php b/tests/PHPStan/Type/data/config-file-resolution/config/rcdup.php new file mode 100644 index 0000000000..02ef0ced9b --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/rcdup.php @@ -0,0 +1,5 @@ + 'from rcdup.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeclosure.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeclosure.php new file mode 100644 index 0000000000..2ecc80f39b --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeclosure.php @@ -0,0 +1,9 @@ + $normalize('A'), +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeconditional.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeconditional.php new file mode 100644 index 0000000000..3d65dee215 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeconditional.php @@ -0,0 +1,7 @@ + 'cli']; +} + +return ['v' => 'web']; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopedoc.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopedoc.php new file mode 100644 index 0000000000..8fbb92c1a0 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopedoc.php @@ -0,0 +1,8 @@ +} */ +return [ + 'items' => new AliasedCollection(), +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeforward.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeforward.php new file mode 100644 index 0000000000..16b9c1905f --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeforward.php @@ -0,0 +1,5 @@ + config('scopeleaf.v'), +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeinclude.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeinclude.php new file mode 100644 index 0000000000..44a80a8db6 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeinclude.php @@ -0,0 +1,7 @@ + $fromInclude, +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeleaf.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeleaf.php new file mode 100644 index 0000000000..acee918303 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeleaf.php @@ -0,0 +1,5 @@ + 'leaf', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopereader.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopereader.php new file mode 100644 index 0000000000..ea8e64a2a3 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopereader.php @@ -0,0 +1,8 @@ + $fromOther, + 'inline' => config('scopeleaf.v'), +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopeself.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopeself.php new file mode 100644 index 0000000000..c106177306 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopeself.php @@ -0,0 +1,10 @@ + 'larastan', + 'early' => $name, + 'late' => config('scopeself.name'), + 'callback' => fn () => config('scopeself.name'), +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/scopevars.php b/tests/PHPStan/Type/data/config-file-resolution/config/scopevars.php new file mode 100644 index 0000000000..31fb89dca6 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/scopevars.php @@ -0,0 +1,7 @@ + $hosts, +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/services/stripe.php b/tests/PHPStan/Type/data/config-file-resolution/config/services/stripe.php new file mode 100644 index 0000000000..66d2b659fe --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/services/stripe.php @@ -0,0 +1,5 @@ + 'from services/stripe.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/webapp.php b/tests/PHPStan/Type/data/config-file-resolution/config/webapp.php new file mode 100644 index 0000000000..9c40078b72 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/webapp.php @@ -0,0 +1,5 @@ + 'from webapp.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/xrcapp.php b/tests/PHPStan/Type/data/config-file-resolution/config/xrcapp.php new file mode 100644 index 0000000000..0d1531e90c --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/xrcapp.php @@ -0,0 +1,5 @@ + 'from xrcapp.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/config/xrcdup.php b/tests/PHPStan/Type/data/config-file-resolution/config/xrcdup.php new file mode 100644 index 0000000000..0402904e55 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/config/xrcdup.php @@ -0,0 +1,5 @@ + 'from xrcdup.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/literal[1]/config/rcliteral.php b/tests/PHPStan/Type/data/config-file-resolution/literal[1]/config/rcliteral.php new file mode 100644 index 0000000000..c32a760e28 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/literal[1]/config/rcliteral.php @@ -0,0 +1,5 @@ + 'from literal[1]/config/rcliteral.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/modules/a/config/rcmoda.php b/tests/PHPStan/Type/data/config-file-resolution/modules/a/config/rcmoda.php new file mode 100644 index 0000000000..520e539765 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/modules/a/config/rcmoda.php @@ -0,0 +1,5 @@ + 'from modules/a/config/rcmoda.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-resolution/modules/b/config/rcmodb.php b/tests/PHPStan/Type/data/config-file-resolution/modules/b/config/rcmodb.php new file mode 100644 index 0000000000..032cf58bd0 --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-resolution/modules/b/config/rcmodb.php @@ -0,0 +1,5 @@ + 'from modules/b/config/rcmodb.php', +]; diff --git a/tests/PHPStan/Type/data/config-file-scope-dependency.php b/tests/PHPStan/Type/data/config-file-scope-dependency.php new file mode 100644 index 0000000000..df625049da --- /dev/null +++ b/tests/PHPStan/Type/data/config-file-scope-dependency.php @@ -0,0 +1,9 @@ +', config('scopevars.hosts')); +} + +function callerWithoutVariable(): void +{ + assertType('non-empty-list', config('scopevars.hosts')); +} + +function phpDocUsesConfigFileImports(): void +{ + assertType('Hypervel\Support\Collection', config('scopedoc.items')); +} + +function returnInsideClosureIsNotTheConfig(): void +{ + assertType('lowercase-string', config('scopeclosure.v')); +} + +function conditionalReturnHasNoSingleValue(): void +{ + assertType('mixed', config('scopeconditional.v')); +} + +function variableFromIncludeIsUnknown(): void +{ + assertType('mixed', config('scopeinclude.v')); +} + +// Unlike upstream, a config file reads the configuration as it stands while the file loads: +// a file's own value is set only after it returns, and later files are not loaded yet. +function selfReference(): void +{ + assertType("'larastan'", config('scopeself.name')); + assertType('null', config('scopeself.early')); + assertType('null', config('scopeself.late')); +} + +// A closure in a config file may run while the file loads or after, so its reads keep the declared type. +function closureInConfigFile(): void +{ + assertType('Closure(): mixed', config('scopeself.callback')); +} + +function readsOtherConfigFile(): void +{ + assertType("'leaf'", config('scopereader.v')); + assertType("'leaf'", config('scopereader.inline')); +} + +function readsLaterConfigFile(): void +{ + assertType('null', config('scopeforward.v')); +} diff --git a/tests/PHPStan/Type/data/config-helper-function.php b/tests/PHPStan/Type/data/config-helper-function.php new file mode 100644 index 0000000000..863cd4de37 --- /dev/null +++ b/tests/PHPStan/Type/data/config-helper-function.php @@ -0,0 +1,27 @@ + 1)); + assertType('non-empty-array', config('foo', ['callback' => fn (): int => 1])); + assertType('string', config('foo', 'strlen')); +} diff --git a/tests/PHPStan/Type/data/config-provider-sources.php b/tests/PHPStan/Type/data/config-provider-sources.php new file mode 100644 index 0000000000..bcc1cbca68 --- /dev/null +++ b/tests/PHPStan/Type/data/config-provider-sources.php @@ -0,0 +1,38 @@ +string('package.key')); + + // The Testbench application's files stay known, and a key only a package file sets is unknown rather than absent. + assertType("array{shared: 'app', app: 'app', ...}", config('merged')); + assertType("'app'", Config::get('merged.shared')); + assertType('mixed', config('merged.package')); + assertType("array{shared: 'app', app: 'app', ...}", config('recursive.nested')); + + // A merge into a framework namespace keeps the framework's entries. + assertType('bool|string|null', config('hashing.driver')); + assertType('mixed', config('hashing.package')); + + // array_merge() renumbers integer keys a package may add; array_replace_recursive() keeps the application's. + assertType('mixed', config('listed')); + assertType("array{'app-a', ...}", config('replaced')); + + // A package's named entries are unknown too. + assertType('mixed', config('package.stores.declared')); +} diff --git a/tests/PHPStan/Type/data/config-repository-method-l12-20.php b/tests/PHPStan/Type/data/config-repository-method-l12-20.php new file mode 100644 index 0000000000..c236504df8 --- /dev/null +++ b/tests/PHPStan/Type/data/config-repository-method-l12-20.php @@ -0,0 +1,15 @@ +collection('foo')); + assertType("Hypervel\Support\Collection<'guard'|'passwords', 'users'|'web'>", $config->collection('auth.defaults')); + + assertType('Hypervel\Support\Collection<0|1|2, 1|2|3>', $config->collection('test.bar')); + assertType('*ERROR*', $config->collection('test.foo')); +} diff --git a/tests/PHPStan/Type/data/config-repository-method.php b/tests/PHPStan/Type/data/config-repository-method.php new file mode 100644 index 0000000000..f5f6808dbb --- /dev/null +++ b/tests/PHPStan/Type/data/config-repository-method.php @@ -0,0 +1,58 @@ +get('foo')); + assertType('array{guard: \'web\', passwords: \'users\'}', $config->get('auth.defaults')); + + assertType('array{1, 2, 3}', $config->get('test.bar')); + assertType("'bar'", $config->get('test.foo')); + assertType("'bar'", $config->get('test.foo', 5)); +} + +class CustomValue +{ +} + +class CustomRepository extends Repository +{ + public function get(array|string $key, mixed $default = null): CustomValue + { + return new CustomValue; + } +} + +class CustomArrayRepository extends Repository +{ + public function array(string $key, mixed $default = null): array + { + return []; + } +} + +class CustomConfig extends Config +{ + public static function get(array|string $key, mixed $default = null): CustomValue + { + return new CustomValue; + } +} + +// A custom implementation keeps its declared type, and so do the inherited getters that +// read through it. Facade calls forward to the bound repository, not the facade's methods. +function customImplementations(CustomRepository $config, CustomArrayRepository $arrayConfig): void +{ + assertType('ConfigRepositoryMethod\CustomValue', $config->get('test.foo')); + assertType('string', $config->string('test.foo')); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $arrayConfig->collection('test.bar')); + assertType("'bar'", $arrayConfig->string('test.foo')); + assertType('ConfigRepositoryMethod\CustomValue', CustomConfig::get('test.foo')); + assertType("'bar'", CustomConfig::string('test.foo')); +} diff --git a/tests/PHPStan/Type/data/config-reproduction.php b/tests/PHPStan/Type/data/config-reproduction.php new file mode 100644 index 0000000000..7e2a4418cf --- /dev/null +++ b/tests/PHPStan/Type/data/config-reproduction.php @@ -0,0 +1,11 @@ +argument('argument')); + assertType('true', $foo->hasOption('option')); + + assertType('mixed', $command->argument('argument')); + + // The closure alternative could define any name, so the union keeps the declaration. + assertType('mixed', $fooOrClosure->argument('argument')); + assertType('mixed', $fooOrClosure->argument('missing')); + + Artisan::command('closure', function (): void { + // Closure commands share one class across unrelated signatures, so they keep the declaration. + assertType('mixed', $this->argument('argument')); + }); +} diff --git a/tests/PHPStan/Type/data/container-make.php b/tests/PHPStan/Type/data/container-make.php new file mode 100644 index 0000000000..93a38df271 --- /dev/null +++ b/tests/PHPStan/Type/data/container-make.php @@ -0,0 +1,53 @@ + $model */ +function test( + Container $container, + ContainerContract $container2, + string $model, +): void { + assertType('Hypervel\Config\Repository', $container->make(Repository::class)); + assertType('Hypervel\Config\Repository', $container->makeWith(Repository::class)); + assertType('Hypervel\Config\Repository', $container->resolve(Repository::class)); + + assertType('Hypervel\Config\Repository', $container2->make(Repository::class)); + // REMOVED: makeWith() and resolve() on the contract; they are concrete container + // methods, and contracts are not given implementation methods. + + assertType('mixed', $container->make($foo)); + assertType('mixed', $container->makeWith($foo)); + assertType('mixed', $container->resolve($foo)); + + assertType('Hypervel\Config\Repository', $container2->get(Repository::class)); + assertType('Hypervel\Config\Repository', $container2->makeTransient(Repository::class)); + assertType('Closure(): Hypervel\Config\Repository', $container2->factory(Repository::class)); + + // Service keys resolve to their bindings rather than to facade aliases with the same name. + assertType('Hypervel\Config\Repository', $container2->make('config')); + assertType('Hypervel\Http\Request', $container2->make('request')); + assertType('Closure(): Hypervel\Config\Repository', $container2->factory('config')); + assertType('mixed', $container2->make('eloquent')); + + // A class keeps its declared type when resolution fails or needs runtime context. + assertType('Hypervel\Contracts\Auth\Authenticatable', $container2->make(Authenticatable::class)); + assertType('Hypervel\Tests\PHPStan\Type\data\FailsAtRuntime', $container2->make('\Hypervel\Tests\PHPStan\Type\data\FailsAtRuntime')); + + // Unknown keys and scalar bindings are mixed, as the container declares. + assertType('mixed', $container2->make('not-bound')); + assertType('mixed', $container2->make('path')); + assertType('mixed', $container2->make(\MissingContainerClass::class)); + + assertType('Hypervel\Database\Eloquent\Model', $container2->make($model)); + assertType('Hypervel\Config\Repository', $container2->make(parameters: [], abstract: 'config')); + assertType('mixed', $container2->make(...['config'])); +} diff --git a/tests/PHPStan/Type/data/contracts.php b/tests/PHPStan/Type/data/contracts.php new file mode 100644 index 0000000000..a46445323a --- /dev/null +++ b/tests/PHPStan/Type/data/contracts.php @@ -0,0 +1,13 @@ +isLocal()); + // REMOVED: $session->ageFlashData(); contracts do not gain methods from their implementations. +} diff --git a/tests/PHPStan/Type/data/custom-eloquent-builder.php b/tests/PHPStan/Type/data/custom-eloquent-builder.php new file mode 100644 index 0000000000..afc2811144 --- /dev/null +++ b/tests/PHPStan/Type/data/custom-eloquent-builder.php @@ -0,0 +1,457 @@ +first()); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::where('email', 'bar')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::foo('foo')->foo('bar')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::type('foo')); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $foo->customModels()->category('foo')); + // A relation returns itself only when the builder method returns the same builder, which cloneWithout() does not. + assertType('CustomEloquentBuilder\CustomEloquentBuilder', $foo->customModels()->cloneWithout(['orders'])); + // applyScopes() returns the same builder when there are no scopes, else a clone. + assertType('CustomEloquentBuilder\CustomEloquentBuilder|Hypervel\Database\Eloquent\Relations\HasMany', $foo->customModels()->applyScopes()); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::whereEmail(['bar'])->type('foo')->whereEmail(['bar'])); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::whereEmail(['bar'])->categories(['foo'])->whereType(['bar'])); + assertType('CustomEloquentBuilder\ModelWithCustomBuilder|null', ModelWithCustomBuilder::whereEmail(['bar'])->type('foo')->first()); + assertType('CustomEloquentBuilder\ModelWithCustomBuilder|null', ModelWithCustomBuilder::whereEmail(['bar'])->type('foo')->first()); + assertType('int<0, max>', $foo->customModels()->count()); + assertType('bool', $foo->customModels()->exists()); + assertType('Hypervel\Database\Eloquent\Collection', ModelWithCustomBuilder::get()); + assertType('CustomEloquentBuilder\ModelWithCustomBuilder', ModelWithCustomBuilder::firstOrFail()); + assertType('CustomEloquentBuilder\ModelWithCustomBuilder', ModelWithCustomBuilder::findOrFail(1)); + assertType('Hypervel\Database\Eloquent\Collection', ModelWithCustomBuilder::findOrFail([1, 2, 3])); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->has('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orHas('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->doesntHave('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orDoesntHave('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereHas('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->withWhereHas('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orWhereHas('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereDoesntHave('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orWhereDoesntHave('users')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->hasMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orHasMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->doesntHaveMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orDoesntHaveMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereHasMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orWhereHasMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereDoesntHaveMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->orWhereDoesntHaveMorph('users', 'types')); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->mergeConstraintsFrom(ModelWithCustomBuilder::query())); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereHas('users')->type('foo')); + assertType('Hypervel\Database\Eloquent\Collection', ModelWithCustomBuilderAndDocBlocks::query()->get()); + assertType('Hypervel\Database\Eloquent\Collection', ModelWithCustomBuilderAndDocBlocks::all()); + assertType('CustomEloquentBuilder\CustomBuilder2', ModelWithCustomBuilderAndDocBlocks::query()); + assertType('CustomEloquentBuilder\NonGenericBuilder', $nonGenericBuilder->skip(5)); + + assertType('CustomEloquentBuilder\ModelWithNonGenericBuilder|null', ModelWithNonGenericBuilder::where('email', 'bar')->first()); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', ModelWithNonGenericBuilder::where('email', 'bar')->orderBy('email')); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', ModelWithNonGenericBuilder::whereEmail('bar')); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', ModelWithNonGenericBuilder::query()); + assertType('list', ModelWithNonGenericBuilder::query()->getColumns()); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', $nonGenericModel->newQuery()); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', ModelWithNonGenericBuilder::withTrashed()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $foo->nonGenericModels()->wherePublishable()); + assertType('Hypervel\Database\Eloquent\Collection', ModelWithNonGenericBuilder::get()); + + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::query()->whereNotIn('id', [1])); + assertType('CustomEloquentBuilder\CustomEloquentBuilder', ModelWithCustomBuilder::whereNotIn('id', [1])); + assertType('string', ModelWithCustomBuilder::query()->getColumns()); + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', ModelWithNonGenericBuilder::query()->whereNotIn('id', [1])); + assertType('int<0, max>', ModelWithCustomBuilder::query()->count()); + + // A method that only exists on the custom query builder returned by toBase(), forwarded + // via __call. Resolving this requires reading the actual query builder class off the + // Eloquent builder's own `toBase()` return type, instead of assuming + // Hypervel\Database\Query\Builder. + assertType('CustomEloquentBuilder\ModelWithCustomQueryBuilder|null', ModelWithCustomQueryBuilder::onlyOnCustomQueryBuilder()->first()); + + // exists() is in BuilderHelper::$passthru and is overridden on the custom query builder + // (not on Hypervel\Database\Query\Builder itself). Forwarding it must still return its + // own declared scalar return type, not fall back to assuming the Eloquent builder itself + // (i.e. treating it as chainable) just because it isn't declared on Hypervel's native + // query builder. + assertType('bool', ModelWithCustomQueryBuilder::where('email', 'bar')->exists()); + + // count() is also passthru, but unlike exists() it is NOT overridden on the custom query + // builder - it is only inherited from Hypervel\Database\Query\Builder. The declaring + // class comparison must account for this (the custom query builder "is" its parent), + // not require an exact class name match, or this would wrongly fall back to the + // chainable Eloquent builder type instead of the real passthru return type. + assertType('int<0, max>', ModelWithCustomQueryBuilder::query()->count()); +} + +/** @param ChildNonGenericBuilder|ModelWithNonGenericBuilder $builder */ +function testNonGenericBuilderDocBlock($builder): void +{ + assertType('CustomEloquentBuilder\ChildNonGenericBuilder', $builder); +} + +/** + * Hypervel: every query entry point runs newModelQuery(), so an override's native builder return applies. + */ +function testNewModelQueryOverride(ModelWithOwnModelQuery $model): void +{ + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', ModelWithOwnModelQuery::query()); + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', $model->newQuery()); + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', $model->newQueryWithoutScopes()); + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', $model->newQueryWithoutRelationships()); + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', ModelWithOwnModelQuery::active()); +} + +/** + * Hypervel: query(), forwarded calls and relations run newQuery(), while the unscoped queries, relationship + * existence queries and toQuery() run newModelQuery(), so a newQuery() override changes only the former. + * + * @param EloquentCollection $models + */ +function testNewQueryOverride(ModelWithOwnQuery $model, EloquentCollection $models, ModelWithParentQuery $parentQueryModel): void +{ + assertType('CustomEloquentBuilder\OwnQueryBuilder', ModelWithOwnQuery::query()); + assertType('CustomEloquentBuilder\OwnQueryBuilder', $model->newQuery()); + assertType('CustomEloquentBuilder\OwnQueryBuilder', ModelWithOwnQuery::where('name', 'a')); + assertType('CustomEloquentBuilder\OwnQueryBuilder', ModelWithOwnQuery::active()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $model->children()->active()); + + assertType('Hypervel\Database\Eloquent\Builder', $model->newModelQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $model->newQueryWithoutScopes()); + assertType('Hypervel\Database\Eloquent\Builder', $models->toQuery()); + ModelWithOwnQuery::whereHas('children', function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + ModelWithOwnQuery::whereHasMorph('subject', [ModelWithOwnQuery::class], function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + // A nested where runs on newQueryWithoutRelationships(); with an operator, the closure builds a subquery. + ModelWithOwnQuery::where(function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + $model->children()->where(function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + ModelWithOwnQuery::query()->orWhere(function ($query): void { + assertType('(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder)', $query); + }); + ModelWithOwnQuery::query()->where(function ($query): void { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 1); + + // An override declaring the base builder, such as one calling its parent, keeps the custom builder. + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', ModelWithParentQuery::query()); + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', $parentQueryModel->newQuery()); +} + +/** + * @property string $email + * @property string $category + * @property string $type + */ +class ModelWithCustomBuilder extends Model +{ + // Dummy relation + /** @return HasMany */ + public function users(): HasMany + { + return $this->hasMany(User::class); + } + + /** + * @param CustomEloquentBuilder $query + * + * @phpstan-return CustomEloquentBuilder + */ + public function scopeFoo(CustomEloquentBuilder $query, string $foo): CustomEloquentBuilder + { + return $query->where(['email' => $foo]); + } + + /** @param CustomEloquentBuilder $query */ + public function scopeLockForUpdate(CustomEloquentBuilder $query): CustomEloquentBuilder + { + return $query; + } + + /** @phpstan-return CustomEloquentBuilder */ + public function testCustomBuilderReturnType(): CustomEloquentBuilder + { + return $this->where('email', 'bar'); + } + + /** + * @param \Hypervel\Database\Query\Builder $query + * @return CustomEloquentBuilder + */ + public function newEloquentBuilder($query): CustomEloquentBuilder + { + return new CustomEloquentBuilder($query); + } +} + +/** + * @template TModel of ModelWithCustomBuilder + * + * @extends Builder + */ +class CustomEloquentBuilder extends Builder +{ + public function testScopeCollision(): void + { + // Unlike upstream, the scope's builder result is this builder, which callScope() passes it. + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->lockForUpdate()); + } + + public function wherePublishable(): static + { + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->whereNotIn('status', ['draft', 'archived'])); + + return $this->whereNotIn('status', ['draft', 'archived']); + } + + public function wherePublished(): static + { + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->where('status', 'published')); + + return $this->where('status', 'published'); + } + + /** @phpstan-return $this Unlike upstream, which treats any builder result as this builder. */ + public function category(string $category): CustomEloquentBuilder + { + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->where('category', $category)); + + return $this->where('category', $category); + } + + /** @phpstan-return CustomEloquentBuilder */ + public function type(string $type): CustomEloquentBuilder + { + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->where(['type' => $type])); + + return $this->where(['type' => $type]); + } + + /** + * @param string[] $categories + * + * @phpstan-return CustomEloquentBuilder + */ + public function categories(array $categories): CustomEloquentBuilder + { + assertType('$this(CustomEloquentBuilder\CustomEloquentBuilder)', $this->whereIn('category', $categories)); + + return $this->whereIn('category', $categories); + } +} + +class FooModel extends Model +{ + /** @return HasMany */ + public function customModels(): HasMany + { + return $this->hasMany(ModelWithCustomBuilder::class); + } + + /** @return HasMany */ + public function nonGenericModels(): HasMany + { + return $this->hasMany(ModelWithNonGenericBuilder::class); + } +} + +/** + * @method static CustomBuilder2|ModelWithCustomBuilderAndDocBlocks newModelQuery() + * @method static CustomBuilder2|ModelWithCustomBuilderAndDocBlocks newQuery() + * @method static CustomBuilder2|ModelWithCustomBuilderAndDocBlocks query() + */ +class ModelWithCustomBuilderAndDocBlocks extends Model +{ + /** + * @param \Hypervel\Database\Query\Builder $query + * @return CustomBuilder2 + */ + public function newEloquentBuilder($query): CustomBuilder2 + { + return new CustomBuilder2($query); + } +} + +/** + * @template TModel of ModelWithCustomBuilderAndDocBlocks + * + * @extends Builder + */ +class CustomBuilder2 extends Builder +{ +} + +/** @property string $email Unlike upstream, which runs without model property checks, whereEmail() needs this column. */ +class ModelWithNonGenericBuilder extends Model +{ + use SoftDeletes; + + /** + * @param \Hypervel\Database\Query\Builder $query + * @return ChildNonGenericBuilder + */ + public function newEloquentBuilder($query): ChildNonGenericBuilder + { + return new ChildNonGenericBuilder($query); + } +} + +/** + * @extends Builder + */ +class NonGenericBuilder extends Builder +{ + public function wherePublishable(): static + { + assertType('$this(CustomEloquentBuilder\NonGenericBuilder)', $this->whereNotIn('status', ['draft', 'archived'])); + + return $this->whereNotIn('status', ['draft', 'archived']); + } +} + +class ChildNonGenericBuilder extends NonGenericBuilder +{ + /** @return list */ + public function getColumns(): array + { + return []; + } +} + +/** + * A query builder with a method that does not exist on Hypervel\Database\Query\Builder. + * Mirrors packages (e.g. mongodb/laravel-mongodb) that replace the query layer entirely. + */ +class CustomQueryBuilder extends \Hypervel\Database\Query\Builder +{ + public function onlyOnCustomQueryBuilder(): static + { + return $this; + } + + /** Overridden here, like mongodb/laravel-mongodb's own Query\Builder::exists() does. */ + public function exists(): bool + { + return true; + } +} + +/** + * @template TModel of ModelWithCustomQueryBuilder + * + * @extends Builder + * + * @method CustomQueryBuilder toBase() + */ +class EloquentBuilderWithCustomQueryBuilder extends Builder +{ +} + +class ModelWithCustomQueryBuilder extends Model +{ + /** + * @param \Hypervel\Database\Query\Builder $query + * @return EloquentBuilderWithCustomQueryBuilder + */ + public function newEloquentBuilder($query): EloquentBuilderWithCustomQueryBuilder + { + return new EloquentBuilderWithCustomQueryBuilder($query); + } +} + +class ModelWithOwnModelQuery extends Model +{ + /** + * Get a new query builder that doesn't have any global scopes or eager loading. + */ + public function newModelQuery(): OwnModelQueryBuilder + { + return (new OwnModelQueryBuilder($this->newBaseQueryBuilder()))->setModel($this); + } +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class OwnModelQueryBuilder extends Builder +{ + /** + * Limit the query to active models. + * + * @return $this + */ + public function active(): static + { + return $this; + } +} + +class ModelWithOwnQuery extends Model +{ + /** + * Get a new query builder for the model's table. + */ + public function newQuery(): OwnQueryBuilder + { + $query = (new OwnQueryBuilder($this->newBaseQueryBuilder()))->setModel($this); + + $this->registerGlobalScopes($query); + + return $query; + } + + /** @return HasMany */ + public function children(): HasMany + { + return $this->hasMany(self::class); + } +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class OwnQueryBuilder extends Builder +{ + /** + * Limit the query to active models. + * + * @return $this + */ + public function active(): static + { + return $this; + } +} + +#[UseEloquentBuilder(OwnModelQueryBuilder::class)] +class ModelWithParentQuery extends Model +{ + /** + * Get a new query builder for the model's table. + */ + public function newQuery(): Builder + { + return parent::newQuery(); + } +} diff --git a/tests/PHPStan/Type/data/custom-eloquent-collection.php b/tests/PHPStan/Type/data/custom-eloquent-collection.php new file mode 100644 index 0000000000..e8af57d6d1 --- /dev/null +++ b/tests/PHPStan/Type/data/custom-eloquent-collection.php @@ -0,0 +1,164 @@ +', Account::query()->fromQuery('select * from accounts')); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->fromQuery('select * from accounts')); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->fromQuery('select * from accounts')); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->fromQuery('select * from accounts')); + + assertType('App\AccountCollection', Account::fromQuery('select * from accounts')); + assertType('Hypervel\Database\Eloquent\Collection', User::fromQuery('select * from accounts')); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::fromQuery('select * from accounts')); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::fromQuery('select * from accounts')); + + assertType('App\AccountCollection', Account::query()->hydrate([['active' => 1], ['active' => 0]])); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->hydrate([['active' => 1], ['active' => 0]])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->hydrate([['active' => 1], ['active' => 0]])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->hydrate([['active' => 1], ['active' => 0]])); + + assertType('App\AccountCollection', Account::hydrate([['active' => 1], ['active' => 0]])); + assertType('Hypervel\Database\Eloquent\Collection', User::hydrate([['active' => 1], ['active' => 0]])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::hydrate([['active' => 1], ['active' => 0]])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::hydrate([['active' => 1], ['active' => 0]])); + + assertType('App\AccountCollection', Account::query()->find([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->find([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->find([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->find([1, 2])); + + assertType('App\AccountCollection', Account::find([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::find([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::find([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::find([1, 2])); + + assertType('App\AccountCollection', Account::query()->findMany([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->findMany([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->findMany([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->findMany([1, 2])); + + assertType('App\AccountCollection', Account::findMany([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::findMany([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::findMany([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::findMany([1, 2])); + + assertType('App\AccountCollection', Account::query()->findOrFail([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->findOrFail([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->findOrFail([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->findOrFail([1, 2])); + + assertType('App\AccountCollection', Account::findOrFail([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', User::findOrFail([1, 2])); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::findOrFail([1, 2])); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::findOrFail([1, 2])); + + // Hypervel: findOr() calls the callback when any of the keys is missing. + assertType('42|App\AccountCollection', Account::query()->findOr([1, 2], fn () => 42)); + assertType('42|Hypervel\Database\Eloquent\Collection', User::query()->findOr([1, 2], fn () => 42)); + assertType('42|App\NonGenericCollection', ModelWithNonGenericCollection::query()->findOr([1, 2], fn () => 42)); + assertType('42|App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->findOr([1, 2], fn () => 42)); + + assertType('42|App\AccountCollection', Account::findOr([1, 2], fn () => 42)); + assertType('42|Hypervel\Database\Eloquent\Collection', User::findOr([1, 2], fn () => 42)); + + assertType('App\AccountCollection', Account::query()->get()); + assertType('Hypervel\Database\Eloquent\Collection', User::query()->get()); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::query()->get()); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::query()->get()); + + assertType('App\AccountCollection', Account::get()); + assertType('Hypervel\Database\Eloquent\Collection', User::get()); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::get()); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::get()); + + assertType('App\AccountCollection', Account::all()); + assertType('Hypervel\Database\Eloquent\Collection', User::all()); + assertType('App\NonGenericCollection', ModelWithNonGenericCollection::all()); + assertType('App\OnlyValueGenericCollection', ModelWithOnlyValueGenericCollection::all()); + + assertType('App\AccountCollection', (new User)->accounts()->get()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->get()); + assertType('App\NonGenericCollection', (new User)->modelsWithNonGenericCollection()->get()); + assertType('App\OnlyValueGenericCollection', (new User)->modelsWithOnlyValueGenericCollection()->get()); + + assertType('App\AccountCollection', (new User)->accounts()->getEager()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->getEager()); + assertType('App\NonGenericCollection', (new User)->modelsWithNonGenericCollection()->getEager()); + assertType('App\OnlyValueGenericCollection', (new User)->modelsWithOnlyValueGenericCollection()->getEager()); + + assertType('App\AccountCollection', (new User)->accounts()->createMany([])); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->createMany([])); + assertType('App\NonGenericCollection', (new User)->modelsWithNonGenericCollection()->createMany([])); + assertType('App\OnlyValueGenericCollection', (new User)->modelsWithOnlyValueGenericCollection()->createMany([])); + + assertType('App\AccountCollection', (new User)->accounts()->active()->get()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->active()->get()); + + assertType('App\AccountCollection', (new User)->accounts); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children); + assertType('App\NonGenericCollection', (new User)->modelsWithNonGenericCollection); + assertType('App\OnlyValueGenericCollection', (new User)->modelsWithOnlyValueGenericCollection); + + // Hypervel: BelongsToMany results carry their pivot, as single results do. + assertType('App\RoleCollection', (new User)->roles()->find([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->find([1, 2])); + + assertType('(App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', (new User)->roles()->find(1)); + assertType('App\RoleCollection', (new User)->roles()->findOrFail([1, 2])); + + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->findOrFail([1, 2])); + assertType('App\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', (new User)->roles()->findOrFail(1)); + + assertType('App\RoleCollection', (new User)->roles()->findMany([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->findMany([1, 2])); + + assertType('App\TransactionCollection', (new User)->transactions()->find([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->find([1, 2])); + assertType('App\Transaction|null', (new User)->transactions()->find(1)); + assertType('App\TransactionCollection', (new User)->transactions()->findOrFail([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->findOrFail([1, 2])); + assertType('App\Transaction', (new User)->transactions()->findOrFail(1)); + assertType('App\TransactionCollection', (new User)->transactions()->findMany([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new Role)->users()->findMany([1, 2])); + assertType('App\AccountCollection', (new Group)->accounts()->find([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->find([1, 2])); + assertType('App\Account|null', (new Group)->accounts()->find(1)); + assertType('App\AccountCollection', (new Group)->accounts()->findOrFail([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->findOrFail([1, 2])); + assertType('App\Account', (new Group)->accounts()->findOrFail(1)); + assertType('App\AccountCollection', (new Group)->accounts()->findMany([1, 2])); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children()->findMany([1, 2])); + assertType('App\AccountCollection', (new User)->accounts->where('active', true)); + assertType('App\AccountCollection', (new User)->accounts->filterByActive()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->children->where('active', true)); +} + +/** + * @param \Hypervel\Database\Eloquent\Collection $posts + * @param \Hypervel\Database\Eloquent\Collection $teams + * @param \Hypervel\Database\Eloquent\Collection $users + * @param \Hypervel\Database\Eloquent\Collection $union + * @param \Hypervel\Database\Eloquent\Collection $ownModelQuery + * @param \Hypervel\Database\Eloquent\Collection $unknown + */ +function testToQuery($posts, $teams, $users, $union, $ownModelQuery, $unknown): void +{ + assertType('App\PostBuilder', $posts->toQuery()); + assertType('App\ChildTeamBuilder', $teams->toQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $users->toQuery()); + assertType('App\ChildTeamBuilder|App\PostBuilder', $union->toQuery()); + // Hypervel: toQuery() runs the model's newModelQuery(), including an override, and an unknown model has an + // unknown builder. + assertType('CustomEloquentBuilder\OwnModelQueryBuilder', $ownModelQuery->toQuery()); + assertType('Hypervel\Database\Eloquent\Builder<*>', $unknown->toQuery()); +} diff --git a/tests/PHPStan/Type/data/custom-query-collections.php b/tests/PHPStan/Type/data/custom-query-collections.php new file mode 100644 index 0000000000..4bd6c9d1ec --- /dev/null +++ b/tests/PHPStan/Type/data/custom-query-collections.php @@ -0,0 +1,109 @@ + $query + */ +function test(MemberBuilder $query, Member $member): void +{ + assertType('CustomQueryCollections\Members', $query->get()); + assertType('Hypervel\Database\Eloquent\Collection', $query->hydrate([])); + + assertType('CustomQueryCollections\Members', $member->children()->get()); + assertType('Hypervel\Database\Eloquent\Collection', $member->children()->hydrate([])); + + assertType('Hypervel\Database\Eloquent\Collection', $member->friends()->get()); + assertType('CustomQueryCollections\Members', $member->friends()->getResults()); +} + +/** + * The legacy PHPDoc union of the collection and an array of models reads as the generic collection. + * + * @param Collection|Member[] $members + */ +function legacyCollectionUnion(Collection $members): void +{ + assertType('Hypervel\Database\Eloquent\Collection', $members); +} + +#[CollectedBy(Members::class)] +class Member extends Model +{ + /** @use HasBuilder> */ + use HasBuilder; + + protected static string $builder = MemberBuilder::class; + + /** @return HasMany */ + public function children(): HasMany + { + return $this->hasMany(Member::class); + } + + /** @return Friends */ + public function friends(): Friends + { + return new Friends(Member::query(), $this, 'friend_id', 'id'); + } +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class Members extends Collection +{ +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class MemberBuilder extends Builder +{ + /** + * Hydrate rows into a plain collection. + * + * @return Collection + */ + public function hydrate(array $items): Collection + { + return new Collection; + } +} + +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * + * @extends HasMany + */ +class Friends extends HasMany +{ + /** + * Get the friends as a plain collection. + * + * @return Collection + */ + public function get(array|string $columns = ['*']): Collection + { + return new Collection; + } +} diff --git a/tests/PHPStan/Type/data/database-transaction.php b/tests/PHPStan/Type/data/database-transaction.php new file mode 100644 index 0000000000..102b444593 --- /dev/null +++ b/tests/PHPStan/Type/data/database-transaction.php @@ -0,0 +1,29 @@ + 1)); + assertType('\'lorem\'', DB::transaction(fn () => 'lorem')); + assertType('8.1', DB::transaction(fn () => 8.1)); + assertType('true', DB::transaction(fn () => true)); + assertType('null', DB::transaction(function () { + echo 'ipsum'; + })); + assertType('float|null', DB::transaction(function () { + $number = rand(); + + if ($number % 2 === 0) { + return null; + } + + return sqrt($number); + })); +} diff --git a/tests/PHPStan/Type/data/eager-load-maps.php b/tests/PHPStan/Type/data/eager-load-maps.php new file mode 100644 index 0000000000..940f5ff824 --- /dev/null +++ b/tests/PHPStan/Type/data/eager-load-maps.php @@ -0,0 +1,113 @@ + $users + */ +function eagerLoads(Collection $users): void +{ + // A nested array continues the path from its key's name, without the column list. + $users->load(['posts:id,title' => ['comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }]]); + + $users->fresh(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }]); +} + +/** + * Aggregate callbacks receive the related model's builder. + * + * @param Collection $users + */ +function relationAggregates(User $user, Collection $users): void +{ + $user->loadCount(['posts' => function ($query): void { + assertType('App\PostBuilder', $query); + }]); + + // withAggregate() splits an exact "{relation} as {alias}" name, ignoring the case of "as". + $users->loadSum(['accounts AS total' => function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }], 'id'); + + // A relation that does not resolve keeps Laravel's Builder<*> callback. + $users->loadExists(['accounts as' => function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder<*>', $query); + }]); +} + +/** + * loadMorph() and its siblings read a model's class key before its morph alias. + * + * @param Collection $comments + * @param LengthAwarePaginator $paginator + */ +function morphEagerLoads(Comment $comment, Collection $comments, LengthAwarePaginator $paginator): void +{ + $comment->loadMorph('commentable', ['post' => ['users' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $relation); + }]]); + + // Group's class name is also Role's alias, so the entry's callback receives either model's relation. + $comments->loadMorph('commentable', [Group::class => ['users' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany|Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }]]); + + // Post's own class key wins, so its alias entry is never read. + $comments->loadMorph('commentable', [ + Post::class => ['users'], + 'post' => ['users' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }], + ]); + + $paginator->loadMorphCount('commentable', ['post' => ['users as people' => function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }]]); +} + +/** + * MorphTo resolves a key through the morph map before using it as a class name. + * + * @param MorphTo $commentable + */ +function morphTypeMaps(MorphTo $commentable): void +{ + $commentable->constrain([ + Group::class => function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }, + 'post' => function ($query): void { + assertType('App\PostBuilder', $query); + }, + 'unknown' => function ($query): void { + assertType('mixed', $query); + }, + ]); + + $commentable->morphWith(['post' => ['users' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $relation); + }]]); + + $commentable->morphWithCount([User::class => ['accounts as total' => function ($query): void { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }]]); +} diff --git a/tests/PHPStan/Type/data/eager-loading-callbacks.php b/tests/PHPStan/Type/data/eager-loading-callbacks.php new file mode 100644 index 0000000000..b07fa93e0c --- /dev/null +++ b/tests/PHPStan/Type/data/eager-loading-callbacks.php @@ -0,0 +1,157 @@ + $builder + * @param 'accounts'|'group' $name + */ +function builderWith(Builder $builder, string $dynamic, string $name): void +{ + assertType('Hypervel\Database\Eloquent\Builder', User::query()->with(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation->where('active', true)->orderBy('id')); + }])); + + assertType('Hypervel\Database\Eloquent\Builder', User::query()->with('accounts')); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->with(['accounts', 'group'])); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->with('accounts', 'group')); + + User::where('id', 1)->with(relations: ['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + + $builder->with(['accounts' => fn ($relation) => assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation)]); + + $builder->with(['posts.comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }]); + + // Hypervel: each key's callback receives that key's own relation, not a union of the keys' relations. + $builder->with([ + 'accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }, + 'group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }, + ]); + + // Hypervel: a key that may be either name receives either relation, as a plain union. + $builder->with([$name => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }]); + + $builder->with([$dynamic => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }]); + + $builder->with(['accounts', 'group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + + $builder->with('accounts', function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }); +} + +/** @param Builder $builder */ +function builderWithOnly(Builder $builder): void +{ + assertType('Hypervel\Database\Eloquent\Builder', $builder->withOnly(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }])); + + assertType('Hypervel\Database\Eloquent\Builder', User::withOnly(['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }])); + + assertType('Hypervel\Database\Eloquent\Builder', User::withOnly('accounts')); +} + +function modelLoad(User $user, string $dynamic): void +{ + assertType('App\User', $user->load(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }])); + + assertType('App\User', $user->load('accounts')); + assertType('App\User', $user->load('accounts', 'group')); + assertType('App\User', $user->load(['accounts', 'group'])); + + $user->load(['posts.comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }]); + + $user->load([$dynamic => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }]); + + assertType('App\User', $user->loadMissing(['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }])); + + assertType('App\User', $user->loadMissing('accounts')); +} + +/** + * @param Collection $users + * @param Collection $mixed + */ +function collectionLoad(Collection $users, Collection $mixed, string $dynamic): void +{ + assertType('Hypervel\Database\Eloquent\Collection', $users->load(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }])); + + assertType('Hypervel\Database\Eloquent\Collection', $users->load('accounts')); + assertType('Hypervel\Database\Eloquent\Collection', $users->load(['accounts', 'group'])); + + $users->load([$dynamic => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }]); + + assertType('Hypervel\Database\Eloquent\Collection', $users->loadMissing(['posts.comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }])); + + assertType('Hypervel\Database\Eloquent\Collection', $users->loadMissing('accounts')); + + // Hypervel: several possible relations form a strict union, not upstream's benevolent one. + $mixed->load(['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo|Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + + // Hypervel: Account has no accounts relation, which a subclass or resolveRelationUsing() could supply, so the + // relation is unknown. + $mixed->load(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); + }]); +} + +function relationWith(User $user): void +{ + assertType('Hypervel\Database\Eloquent\Relations\BelongsToMany', $user->posts()->with(['comments' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $relation); + }])); +} + +class ChildUser extends User +{ + public function insideClass(): void + { + static::query()->with(['accounts' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $relation); + }]); + $this->load(['group' => function ($relation): void { + assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $relation); + }]); + } +} diff --git a/tests/PHPStan/Type/data/eloquent-builder-l11-42.php b/tests/PHPStan/Type/data/eloquent-builder-l11-42.php new file mode 100644 index 0000000000..7029b839c5 --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-builder-l11-42.php @@ -0,0 +1,384 @@ + $userBuilder + * @param Builder $userOrTeamBuilder + * @param Builder $templateBuilder + */ +function test( + User $user, + Post $post, + Builder $userBuilder, + OnlyUsers&User $userAndAuth, + Builder $userOrTeamBuilder, + Builder $templateBuilder, +): void { + User::query()->has('accounts', '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->has('users', '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->doesntHave('accounts', 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->doesntHave('users', 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->whereHas('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->whereHas('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->orWhereHas('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->orWhereHas('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->hasMorph('commentable', [Account::class], '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->hasMorph('commentable', [User::class], '=', 1, 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->doesntHaveMorph('commentable', [Account::class], 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->doesntHaveMorph('commentable', [User::class], 'and', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereHasMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->orWhereHasMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->orWhereHasMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereDoesntHaveMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->whereDoesntHaveMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->orWhereDoesntHaveMorph('commentable', [Account::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Comment::query()->orWhereDoesntHaveMorph('commentable', [User::class], function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->whereDoesntHave('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->whereDoesntHave('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->orWhereDoesntHave('accounts', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->orWhereDoesntHave('users', function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->firstWhere(function (Builder $query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + Post::query()->firstWhere(function (PostBuilder $query) { + assertType('App\PostBuilder', $query); + }); + + Post::query()->where(static function (PostBuilder $query) { + assertType('App\PostBuilder', $query + ->orWhere('bar', 'LIKE', '%foo%') + ->orWhereRelation('users', 'name', 'LIKE', '%foo%')); + }); + + assertType('Hypervel\Database\Eloquent\Collection', User::where([ + ['active', true], + ['id', '>=', 5], + ['id', '<=', 10], + ])->get()); + + assertType('Hypervel\Database\Eloquent\Collection', User::where('id', 1)->get()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->where('id', 1)->get()); + // Unlike upstream, which runs without model property checks, these dynamic wheres name a real column. + assertType('Hypervel\Database\Eloquent\Collection', User::where('id', 1) + ->whereNotNull('name') + ->where('email', 'bar') + ->whereName(['bar']) + ->get()); + assertType('Hypervel\Database\Eloquent\Collection', (new User)->whereNotNull('name') + ->where('email', 'bar') + ->whereName(['bar']) + ->get()); + // Unlike upstream, User's public email() accessor applies, as it does in Eloquent, so email reads as mixed. + assertType('Hypervel\Support\Collection', User::whereIn('id', [1, 2, 3])->get()->mapWithKeys(function (User $user): array { + return [$user->name => $user->email]; + })); + + assertType('mixed', (new User)->where('email', 1)->max('email')); + assertType('bool', (new User)->where('email', 1)->exists()); + assertType('Hypervel\Database\Eloquent\Builder', User::with('accounts')->whereNull('name')); + assertType('Hypervel\Database\Eloquent\Builder', User::with('accounts') + ->where('email', 'bar') + ->orWhere('name', 'baz')); + + assertType('App\User|null', User::with(['accounts'])->find(1)); + assertType('App\User', User::with(['accounts'])->findOrFail(1)); + assertType('App\User', User::with(['accounts'])->findOrNew(1)); + assertType('Hypervel\Database\Eloquent\Model|null', (new CustomBuilder(User::query()->getQuery()))->with('email')->find(1)); + + assertType('Hypervel\Database\Eloquent\Collection', User::with(['accounts'])->find([1, 2, 3])); + assertType('Hypervel\Database\Eloquent\Collection', User::with(['accounts'])->findOrNew([1, 2, 3])); + assertType('Hypervel\Database\Eloquent\Collection', User::hydrate([])); + assertType('Hypervel\Database\Eloquent\Collection', User::fromQuery('SELECT * FROM users')); + + assertType('Hypervel\Database\Eloquent\Builder', $userBuilder->whereNotNull('test')); + + assertType('Hypervel\Database\Eloquent\Builder', $user->newQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $user->newModelQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $user->newQueryWithoutRelationships()); + assertType('Hypervel\Database\Eloquent\Builder', $user->newQueryWithoutScopes()); + assertType('Hypervel\Database\Eloquent\Builder', $user->newQueryWithoutScope('foo')); + assertType('Hypervel\Database\Eloquent\Builder', $user->newQueryForRestoration([1])); + + assertType('App\PostBuilder', $post->newQuery()); + assertType('App\PostBuilder', $post->newModelQuery()); + assertType('App\PostBuilder', $post->newQueryWithoutRelationships()); + assertType('App\PostBuilder', $post->newQueryWithoutScopes()); + assertType('App\PostBuilder', $post->newQueryWithoutScope('foo')); + assertType('App\PostBuilder', $post->newQueryForRestoration([1])); + + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newModelQuery()); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newQueryWithoutRelationships()); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newQueryWithoutScopes()); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newQueryWithoutScope('foo')); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth->newQueryForRestoration([1])); + assertType('Hypervel\Database\Eloquent\Builder', $userAndAuth::query()); + + assertType('Hypervel\Support\LazyCollection', User::query()->lazy()); + assertType('Hypervel\Support\LazyCollection', User::query()->lazyById()); + assertType('Hypervel\Support\LazyCollection', User::query()->lazyByIdDesc()); + assertType('Hypervel\Support\LazyCollection', User::query()->cursor()); + assertType('Hypervel\Support\LazyCollection', $post->newQuery()->lazy()); + assertType('Hypervel\Support\LazyCollection', $post->newQuery()->lazyById()); + assertType('Hypervel\Support\LazyCollection', $post->newQuery()->lazyByIdDesc()); + + assertType('Hypervel\Database\Eloquent\Builder', User::query()->groupBy('foo', 'bar')); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->whereEmail('bar')); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->whereIdAndEmail(1, 'foo@example.com')); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->whereEmail(1)); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNull('name') + ->orderBy('email') + ->toBase() + ); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNotBetween('a', [1, 5]) + ->orWhereNotBetween('a', [1, 5]) + ->toBase() + ); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->withTrashed()); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNull('name') + ->orderBy(\Hypervel\Support\Facades\DB::raw('name')) + ->toBase() + ); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNull('name') + ->orderBy(User::whereNotNull('name')) + ->toBase() + ); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNull('name') + ->latest(\Hypervel\Support\Facades\DB::raw('created_at')) + ->toBase() + ); + assertType('Hypervel\Database\Query\Builder', User::query() + ->whereNull('name') + ->oldest(\Hypervel\Support\Facades\DB::raw('created_at')) + ->toBase() + ); + assertType('Hypervel\Support\Collection<(int|string), mixed>', User::query() + ->whereNull('name') + ->pluck(\Hypervel\Support\Facades\DB::raw('created_at')) + ->toBase() + ); + assertType('int', User::query()->increment(\Hypervel\Support\Facades\DB::raw('counter'))); + assertType('int', User::query()->decrement(\Hypervel\Support\Facades\DB::raw('counter'))); + // Unlike upstream, registering a local macro returns null, as Builder::__call() does. + assertType('null', $userBuilder->macro('customMacro', function () { + })); + assertType('string', $userBuilder->globalCustomMacro('foo')); + assertType('App\User', User::with('accounts') + ->where('email', 'bar') + ->orWhere('name', 'baz') + ->firstOrFail() + ); + assertType('App\User|null', User::with('accounts') + ->where('email', 'bar') + ->orWhere('name', 'baz') + ->first() + ); + assertType('App\User|null', User::query()->firstWhere(['email' => 'foo@bar.com'])); + assertType('App\User|null', User::with('accounts') + ->orWhere(\Hypervel\Support\Facades\DB::raw('name'), 'like', '%john%') + ->first() + ); + assertType('App\User|null', User::with('accounts') + ->where(\Hypervel\Support\Facades\DB::raw('name'), 'like', '%john%') + ->first() + ); + assertType('App\User|null', User::with('accounts') + ->firstWhere(\Hypervel\Support\Facades\DB::raw('name'), 'like', '%john%') + ); + assertType('mixed', User::with('accounts') + ->value(\Hypervel\Support\Facades\DB::raw('name')) + ); + assertType('int', User::query()->restore()); + assertType('Hypervel\Database\Eloquent\Builder', User::query()->joinSub( + Post::query()->whereIn('id', [1, 2, 3]), + 'users', + 'users.id', + 'posts.id' + )); + + // Unlike upstream, paginators carry their collection class as a third template. + assertType('Hypervel\Pagination\LengthAwarePaginator>', User::query()->paginate()); + assertType('array', User::query()->paginate()->items()); + + User::chunk(1000, fn ($collection) => assertType('Hypervel\Database\Eloquent\Collection', $collection)); + User::chunkById(1000, fn ($collection) => assertType('Hypervel\Database\Eloquent\Collection', $collection)); + assertType('Hypervel\Support\Collection', User::chunkMap(function ($model) { + assertType('App\User', $model); + + return $model->name; + }, 1000)); + $userBuilder->chunk(1000, fn ($collection) => assertType('Hypervel\Database\Eloquent\Collection', $collection)); + $userBuilder->chunkById(1000, fn ($collection) => assertType('Hypervel\Database\Eloquent\Collection', $collection)); + assertType('Hypervel\Support\Collection', $userBuilder->chunkMap(function ($model) { + assertType('App\User', $model); + + return $model->name; + }, 1000)); + + assertType('App\Team|App\User', $userOrTeamBuilder->findOrFail(4)); + assertType('Hypervel\Database\Eloquent\Builder', $userOrTeamBuilder->where('id', 5)); + + assertType('Hypervel\Database\Eloquent\Builder', $templateBuilder->select()); +} + +class Foo extends Model +{ + /** @use FooTrait */ + use FooTrait; +} + +/** @template TModel of Model */ +trait FooTrait +{ + /** @return Builder */ + public function doFoo(): Builder + { + return $this->newQuery(); + } +} + +/** @property string $email */ +class TestModel extends Model +{ + public function test(): void + { + // Unlike upstream, forwarding through a model keeps its late-static type, as static::query() does. + assertType('Hypervel\Database\Eloquent\Collection', $this->where('email', 1)->get()); + assertType('Hypervel\Database\Eloquent\Builder', static::query()->where('email', 'bar')); + assertType('Hypervel\Database\Eloquent\Builder', $this->where('email', 'bar')); + } +} + +/** @extends Builder */ +class CustomBuilder extends Builder +{ +} + +/** @template TModel of User|Team */ +abstract class UnionClass +{ + /** @return TModel */ + public function test(int $id): Model + { + assertType('TModel of App\Team|App\User (class EloquentBuilder\UnionClass, argument)', $this->getQuery()->findOrFail($id)); + + return $this->getQuery()->findOrFail($id); + } + + /** @return Builder */ + abstract public function getQuery(): Builder; +} + +/** @extends UnionClass */ +class TeamClass extends UnionClass +{ + public function foo(): void + { + assertType('App\Team', $this->test(5)); + } + + /** @inheritDoc */ + public function getQuery(): Builder + { + return Team::query(); + } +} diff --git a/tests/PHPStan/Type/data/eloquent-builder-l12-19.php b/tests/PHPStan/Type/data/eloquent-builder-l12-19.php new file mode 100644 index 0000000000..1c195c1553 --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-builder-l12-19.php @@ -0,0 +1,68 @@ + */ +class CustomBuilder extends \Hypervel\Database\Eloquent\Builder {} + +/** @extends Builder */ +class AnotherCustomBuilder extends \Hypervel\Database\Eloquent\Builder {} + +function test(): void +{ + assertType('EloquentBuilder1215\CustomBuilder', A::query()); + assertType('EloquentBuilder1215\AnotherCustomBuilder', B::query()); + assertType('EloquentBuilder1215\CustomBuilder', C::query()); + assertType('EloquentBuilder1215\CustomBuilder', D::query()); + assertType('EloquentBuilder1215\CustomBuilder', E::query()); + assertType('Hypervel\Database\Eloquent\Builder', F::query()); + + // Unlike upstream, which reads a class constant's class as the builder, the constant's value names it, as + // instantiating the attribute reads it. + assertType('EloquentBuilder1215\CustomBuilder', G::query()); + assertType('EloquentBuilder1215\CustomBuilder', H::query()); +} diff --git a/tests/PHPStan/Type/data/eloquent-builder-l13-31.php b/tests/PHPStan/Type/data/eloquent-builder-l13-31.php new file mode 100644 index 0000000000..d0d422737f --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-builder-l13-31.php @@ -0,0 +1,128 @@ +', Post::query()); + assertType('EloquentBuilderL1331\BaseBuilder', Post::where('id', 1)); + assertType('EloquentBuilderL1331\BaseBuilder', Comment::query()); + assertType('EloquentBuilderL1331\VideoBuilder', Video::query()); + assertType('Hypervel\Database\Eloquent\Builder', Image::query()); + assertType('EloquentBuilderL1331\BaseBuilder', Tag::query()); + assertType('EloquentBuilderL1331\BaseBuilder', TraitParentChild::query()); + assertType('EloquentBuilderL1331\VideoBuilder', TraitChild::query()); + assertType('Hypervel\Database\Eloquent\Builder', NestedTraitModel::query()); + assertType('Hypervel\Database\Eloquent\Builder', InvalidBuilderChild::query()); +} + +#[UseEloquentBuilder(BaseBuilder::class)] +abstract class BaseModel extends Model +{ + +} + +class Post extends BaseModel +{ + +} + +class Comment extends Post +{ + +} + +#[UseEloquentBuilder(VideoBuilder::class)] +class Video extends BaseModel +{ + +} + +class Image extends BaseModel +{ + /** @return Builder */ + public function newEloquentBuilder($query): Builder + { + return new Builder($query); + } +} + +#[UseEloquentBuilder(BaseBuilder::class)] +trait HasBaseBuilder +{ + +} + +#[UseEloquentBuilder(VideoBuilder::class)] +trait HasVideoBuilder +{ + +} + +trait UsesHasBaseBuilder +{ + use HasBaseBuilder; +} + +class Tag extends Model +{ + use HasBaseBuilder; +} + +abstract class TraitParent extends Model +{ + use HasBaseBuilder; +} + +class TraitParentChild extends TraitParent +{ + +} + +class TraitChild extends BaseModel +{ + use HasVideoBuilder; +} + +/** Hypervel only reads the traits the class uses itself, so the attribute of HasBaseBuilder is skipped. */ +class NestedTraitModel extends Model +{ + use UsesHasBaseBuilder; +} + +/** Hypervel ignores a class that is not an Eloquent builder. */ +#[UseEloquentBuilder(Model::class)] +abstract class InvalidBuilderParent extends Model +{ + +} + +class InvalidBuilderChild extends InvalidBuilderParent +{ + +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class BaseBuilder extends Builder +{ + +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class VideoBuilder extends Builder +{ + +} diff --git a/tests/PHPStan/Type/data/eloquent-collection-map.php b/tests/PHPStan/Type/data/eloquent-collection-map.php new file mode 100644 index 0000000000..dbdd178083 --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-collection-map.php @@ -0,0 +1,244 @@ + $users + * @param EloquentCollection $keyedUsers + * @param TransactionCollection $transactions + * @param OnlyValueGenericCollection $onlyValue + */ +function neverReturnsModel( + EloquentCollection $users, + EloquentCollection $keyedUsers, + TransactionCollection $transactions, + UserCollection $userCollection, + OnlyValueGenericCollection $onlyValue, +): void { + // Hypervel: $user->id is int here and below, from the incrementing key's implicit cast. + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => $user->id)); + assertType('Hypervel\Support\Collection', $users->map(function ($user) { + return $user->name; + })); + assertType('Hypervel\Support\Collection', $keyedUsers->map(fn ($user, $key) => $key)); + assertType('Hypervel\Support\Collection', $transactions->map(fn ($transaction) => $transaction->id)); + assertType('Hypervel\Support\Collection', $userCollection->map(fn ($user) => $user->name)); + assertType('Hypervel\Support\Collection', $onlyValue->map(fn ($model) => 1)); + assertType('Hypervel\Support\Collection>', $users->map(fn ($user) => $user->accounts)); + + // Constant values are generalized, so the resulting collection can be written to and returned. + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => 'foo')); + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => true)); + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => ['id' => $user->id, 'name' => $user->name])); + assertType('Hypervel\Support\Collection', User::query()->get()->map(fn ($user) => ['id' => $user->id, 'name' => $user->name])); + + assertType('Hypervel\Support\Collection', $users->mapWithKeys(fn ($user) => [$user->id => 0.0])); + assertType('Hypervel\Support\Collection', $users->mapWithKeys(fn ($user) => ['foo' => $user->id])); + assertType('Hypervel\Support\Collection', $users->mapWithKeys(fn ($user) => [$user->name => ['a' => 1]])); + assertType('Hypervel\Support\Collection', $users->mapWithKeys(fn ($user) => ['a' => 1, 'b' => 'x'])); + assertType('Hypervel\Support\Collection', $transactions->mapWithKeys(fn ($transaction) => [$transaction->id => 'foo'])); + assertType('Hypervel\Support\Collection', $onlyValue->mapWithKeys(fn ($model) => ['a' => 1])); +} + +/** + * A callback that always returns a model keeps the collection class it was called on. + * + * @param EloquentCollection $users + * @param EloquentCollection $keyedUsers + * @param TransactionCollection $transactions + * @param OnlyValueGenericCollection $onlyValue + */ +function alwaysReturnsModel( + EloquentCollection $users, + EloquentCollection $keyedUsers, + TransactionCollection $transactions, + UserCollection $userCollection, + OnlyValueGenericCollection $onlyValue, +): void { + assertType('Hypervel\Database\Eloquent\Collection', $users->map(fn ($user) => $user)); + assertType('Hypervel\Database\Eloquent\Collection', $users->map(fn ($user) => new Account())); + assertType('Hypervel\Database\Eloquent\Collection', $users->map(fn ($user) => $user->id > 5 ? $user : new Account())); + assertType('Hypervel\Database\Eloquent\Collection', $keyedUsers->map(fn ($user) => $user)); + assertType('App\TransactionCollection', $transactions->map(fn ($transaction) => $transaction)); + assertType('App\TransactionCollection', $transactions->map(fn ($transaction) => new User())); + assertType('App\UserCollection', $userCollection->map(fn ($user) => $user)); + assertType('App\OnlyValueGenericCollection', $onlyValue->map(fn ($model) => $model)); + assertType('App\OnlyValueGenericCollection', $onlyValue->map(fn ($model) => new User())); + + assertType('Hypervel\Database\Eloquent\Collection', $users->mapWithKeys(fn ($user) => [$user->name => $user])); + assertType('Hypervel\Database\Eloquent\Collection', $users->mapWithKeys(fn ($user) => [$user->name => $user, 5 => $user])); + assertType('App\TransactionCollection', $transactions->mapWithKeys(fn ($transaction) => [$transaction->id => $transaction])); + assertType('App\TransactionCollection', $transactions->mapWithKeys(fn ($transaction) => ['user' => new User()])); + // Hypervel: known PHPStan limitation. A static<...> return keeps each class's fixed int keys, so these two hold + // string keys at runtime. + assertType('App\UserCollection', $userCollection->mapWithKeys(fn ($user) => [$user->name => $user])); + assertType('App\OnlyValueGenericCollection', $onlyValue->mapWithKeys(fn ($model) => ['a' => $model])); +} + +/** + * One value that is not a model turns the whole result into a base collection, + * so a callback that might return either cannot promise an Eloquent collection. + * + * @param EloquentCollection $users + * @param EloquentCollection $keyedUsers + * @param TransactionCollection $transactions + * @param OnlyValueGenericCollection $onlyValue + * @param EloquentCollection|null $nullableUsers + * @param callable(User): (User|null) $nullableUserCallback + */ +function mightReturnModel( + EloquentCollection $users, + EloquentCollection $keyedUsers, + TransactionCollection $transactions, + OnlyValueGenericCollection $onlyValue, + EloquentCollection|null $nullableUsers, + callable $callback, + callable $nullableUserCallback, +): void { + assertType('Hypervel\Support\Collection', $users->map(fn (User $user): ?User => $user->id > 5 ? $user : null)); + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => $user->id > 5 ? $user : null)); + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => $user->group)); + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => $user->id > 5 ? $user : $user->name)); + assertType('Hypervel\Support\Collection', $users->map($callback)); + assertType('Hypervel\Support\Collection', $users->map($nullableUserCallback)); + assertType('Hypervel\Support\Collection', $keyedUsers->map(fn ($user) => $user->id > 5 ? $user : null)); + assertType('Hypervel\Support\Collection', $transactions->map(fn ($transaction) => $transaction->id > 5 ? $transaction : null)); + assertType('Hypervel\Support\Collection', $onlyValue->map(fn ($model) => $model->exists ? $model : null)); + assertType('Hypervel\Support\Collection|null', $nullableUsers?->map(fn ($user) => $user->id > 5 ? $user : null)); + + assertType('Hypervel\Support\Collection', $users->mapWithKeys(fn ($user) => [$user->name => $user->id > 5 ? $user : null])); + assertType('Hypervel\Support\Collection', $transactions->mapWithKeys(fn ($transaction) => [$transaction->id => $transaction->id > 5 ? $transaction : null])); + + // The result is a base collection even after the values that are not models are removed. + assertType('Hypervel\Support\Collection', $users->map(fn ($user) => $user->id > 5 ? $user : null)->filter()); +} + +/** + * @param EloquentCollection $users + * @param EloquentCollection|EloquentCollection $usersOrAccounts + * @param EloquentCollection|SupportCollection $usersOrIntegers + * @param EloquentCollection|LazyCollection $usersOrLazyUsers + */ +function callablesAndReceivers( + EloquentCollection $users, + EloquentCollection $usersOrAccounts, + EloquentCollection|SupportCollection $usersOrIntegers, + EloquentCollection|LazyCollection $usersOrLazyUsers, +): void { + assertType('Hypervel\Support\Collection', $users->map(UserMapper::toId(...))); + assertType('Hypervel\Support\Collection', $users->map([UserMapper::class, 'toId'])); + assertType('Hypervel\Support\Collection', $users->map(UserMapper::toNullableUser(...))); + assertType('Hypervel\Support\Collection', $users->map(new InvokableUserMapper())); + // Hypervel: $user->id is int, from the incrementing key's implicit cast. + assertType('Hypervel\Support\Collection', $users->map(callback: fn ($user) => $user->id)); + assertType('Hypervel\Support\Collection', $users->map(callback: fn ($user) => $user->id > 5 ? $user : null)); + + assertType('Hypervel\Database\Eloquent\Collection', $usersOrAccounts->map(fn ($model) => $model)); + assertType('Hypervel\Support\Collection', $usersOrAccounts->map(fn ($model) => $model->exists ? $model : null)); + assertType('Hypervel\Support\Collection', $usersOrIntegers->map(fn ($value) => 'foo')); + assertType('Hypervel\Support\Collection', $usersOrIntegers->map(fn ($value) => $value)); + assertType('Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $usersOrLazyUsers->map(fn ($user) => $user->exists ? $user : null)); +} + +/** + * Laravel keeps the collection class when every mapped value is a model, whatever model that is. A class that pins + * its key or model type would stay in the result while claiming to hold its own model, so it is left out. + * + * @param OnlyValueGenericCollection $pinnedKey + * @param PinnedModelCollection $pinnedModel + */ +function collectionsThatPinTheirTypes( + UserCollection $pinnedKeyAndModel, + OnlyValueGenericCollection $pinnedKey, + PinnedModelCollection $pinnedModel, +): void { + assertType('Hypervel\Support\Collection', $pinnedKeyAndModel->map(fn ($user) => $user->exists ? $user : null)); + assertType('Hypervel\Support\Collection', $pinnedKeyAndModel->map(fn ($user) => $user->group)); + assertType('App\Group|null', $pinnedKeyAndModel->map(fn ($user) => $user->group)->first()); + assertType('Hypervel\Support\Collection', $pinnedKeyAndModel->map(fn ($user) => $user->id > 5 ? new Account() : null)); + assertType('Hypervel\Support\Collection', $pinnedKeyAndModel->mapWithKeys(fn ($user) => [$user->name => $user->id > 5 ? new Account() : null])); + + assertType('Hypervel\Support\Collection', $pinnedKey->mapWithKeys(fn ($model) => ['a' => $model->exists ? $model : null])); + assertType('Hypervel\Support\Collection', $pinnedModel->map(fn ($user) => $user->group)); +} + +/** + * @template TKey of array-key + * @template TModel of \Hypervel\Database\Eloquent\Model + * + * @extends EloquentCollection + */ +class InsideCollection extends EloquentCollection +{ + public function test(): void + { + assertType('static(EloquentCollectionMap\InsideCollection)', $this->map(fn ($model) => $model)); + assertType('Hypervel\Support\Collection', $this->map(fn ($model) => $model->exists)); + assertType('Hypervel\Support\Collection', $this->map(fn ($model) => $model->exists ? $model : null)); + } +} + +/** + * @template TKey of array-key + * + * @extends EloquentCollection + */ +class PinnedModelCollection extends EloquentCollection +{ +} + +/** @extends EloquentCollection */ +class OverridesMapCollection extends EloquentCollection +{ + /** + * @param callable(User, int): mixed $callback + * + * @return $this + */ + public function map(callable $callback): static + { + return $this->each($callback); + } +} + +function overriddenMethod(OverridesMapCollection $collection): void +{ + assertType('EloquentCollectionMap\OverridesMapCollection', $collection->map(fn ($user) => $user->id > 5 ? $user : null)); + assertType('Hypervel\Support\Collection', $collection->mapWithKeys(fn ($user) => [$user->name => $user->id > 5 ? $user : null])); +} + +class UserMapper +{ + public static function toId(User $user): int + { + return $user->id; + } + + public static function toNullableUser(User $user): User|null + { + return $user->id > 5 ? $user : null; + } +} + +class InvokableUserMapper +{ + public function __invoke(User $user): string + { + return $user->name; + } +} diff --git a/tests/PHPStan/Type/data/eloquent-getter-types.php b/tests/PHPStan/Type/data/eloquent-getter-types.php new file mode 100644 index 0000000000..b24d79813d --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-getter-types.php @@ -0,0 +1,16 @@ +', $user->getCasts()); + // Hypervel: setTouchedRelations() and setAppends() accept any array of names. + assertType('array', $user->getTouchedRelations()); + assertType('array', $user->getAppends()); + assertType('list', $user->getMutatedAttributes()); +} diff --git a/tests/PHPStan/Type/data/eloquent-where.php b/tests/PHPStan/Type/data/eloquent-where.php new file mode 100644 index 0000000000..7ab6954c0d --- /dev/null +++ b/tests/PHPStan/Type/data/eloquent-where.php @@ -0,0 +1,120 @@ +', User::query()->where(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + + User::query()->where(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }, null, 2); + + assertType('Hypervel\Database\Eloquent\Builder', User::query()->where(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2)); + + User::where(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, 2); + + $user->where(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', null); + + assertType('App\User|null', User::query()->firstWhere(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + + assertType('App\User|null', User::firstWhere(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2)); + + User::query()->whereNot(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }, null, null, 'or'); + + User::query()->whereNot(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2); + + User::query()->orWhereNot(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }, null); + + User::query()->orWhereNot(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2); + + Post::where(function ($query) { + assertType('App\PostBuilder', $query); + }); + + Post::query()->where(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2); + + $user->posts()->where(function ($query) { + assertType('App\PostBuilder', $query); + }); + + $user->posts()->where(function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }, '=', 2); + + User::query()->where(value: 2, column: function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + }); + + User::query()->where(operator: '=', value: 2, column: function ($query) { + assertType('Hypervel\Database\Query\Builder', $query); + }); + + User::query()->where(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder', $query); + }, $operator, 2); + + User::query()->where(function ($query) { + assertType('Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder', $query); + }, $unknown, 2); + + User::query()->orWhere(function ($query) { + assertType('(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder)', $query); + }); + + User::query()->orWhere(function ($query) { + assertType('(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder)', $query); + }, null); + + User::query()->orWhere(function ($query) { + assertType('(Hypervel\Database\Eloquent\Builder|Hypervel\Database\Query\Builder)', $query); + }, null, null); + + Post::orWhere(function ($query) { + assertType('(App\PostBuilder|Hypervel\Database\Query\Builder)', $query); + }, '=', 2); +} + +/** + * @param Builder $unknown + */ +function dynamicWheres(Builder $unknown, User $user): void +{ + // Any column may belong to a subclass of an abstract model. + assertType('Hypervel\Database\Eloquent\Builder', $unknown->whereNameAndEmail('Taylor', 'taylor@example.com')); + + // Unlike upstream, a missing column does not remove the method: ModelPropertyColumnRule reports it. + assertType('Hypervel\Database\Eloquent\Builder', User::query()->whereMissingColumn(1)); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->accounts()->whereMissingColumn(1)); +} diff --git a/tests/PHPStan/Type/data/environment-helper.php b/tests/PHPStan/Type/data/environment-helper.php new file mode 100644 index 0000000000..cf9511fcef --- /dev/null +++ b/tests/PHPStan/Type/data/environment-helper.php @@ -0,0 +1,34 @@ +environment()); + assertType('bool', app()->environment('local')); + assertType('bool', app()->environment(['local', 'production'])); +} + +/** + * Hypervel: unpacked arguments pass values only when the array is not empty. + * + * @param list $maybeEmpty + * @param non-empty-list $nonEmpty + * @param array{} $empty + */ +function unpackedArguments(array $maybeEmpty, array $nonEmpty, array $empty): void +{ + assertType('bool|string', app()->environment(...$maybeEmpty)); + assertType('bool', app()->environment(...$nonEmpty)); + assertType('string', app()->environment(...$empty)); + assertType('bool', app()->environment(...$maybeEmpty, ...['local'])); + assertType('bool', app()->environment(environments: 'local')); + + assertType('string', App::environment()); + assertType('bool', App::environment('local')); + assertType('bool|string', App::environment(...$maybeEmpty)); +} diff --git a/tests/PHPStan/Type/data/event-dispatcher.php b/tests/PHPStan/Type/data/event-dispatcher.php new file mode 100644 index 0000000000..9bd517a94d --- /dev/null +++ b/tests/PHPStan/Type/data/event-dispatcher.php @@ -0,0 +1,29 @@ +dispatch('event', [], true)); + assertType('mixed', $contract->dispatch('event', [], true)); + assertType('mixed', Event::dispatch('event', [], true)); + assertType('mixed', $dispatcher->dispatch('event', halt: $halt)); + assertType('list|null', $dispatcher->dispatch('event')); + assertType('array|null', $contract->dispatch('event')); + assertType('array|null', Event::dispatch('event')); + assertType('mixed', Event::dispatch(halt: true, event: new \stdClass())); +} + +function fake(): void +{ + assertType('Hypervel\Support\Collection', Event::dispatched('event')); + assertType('bool', Event::hasDispatched('event')); + assertType('array', Event::dispatchedEvents()); + assertType('Hypervel\Support\Testing\Fakes\EventFake', Event::except('event')); + assertType('null', Event::assertDispatched('event')); +} diff --git a/tests/PHPStan/Type/data/facade-fakes.php b/tests/PHPStan/Type/data/facade-fakes.php new file mode 100644 index 0000000000..3b2554ee07 --- /dev/null +++ b/tests/PHPStan/Type/data/facade-fakes.php @@ -0,0 +1,24 @@ + '123', + )); + assertType('123', Cache::rememberForever( + key: 'cache-key', + callback: static fn (): int => 123, + )); +} diff --git a/tests/PHPStan/Type/data/facades-l12-41.php b/tests/PHPStan/Type/data/facades-l12-41.php new file mode 100644 index 0000000000..cf83e92e28 --- /dev/null +++ b/tests/PHPStan/Type/data/facades-l12-41.php @@ -0,0 +1,42 @@ +get('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::withHeaders(['X-Foo' => 'bar'])->post('https://example.test')); + + assertType('int', Http::get('https://example.test')->getStatusCode()); + + // Hypervel: async requests return Guzzle's promise. Laravel's LazyPromise serves only the + // omitted pool() and batch() concurrency. + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->get('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->post('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->send('GET', 'https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::timeout(30)->async()->get('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::withHeaders(['X-Foo' => 'bar'])->async()->post('https://example.test')); + + assertType('Hypervel\Contracts\Cache\Repository', Cache::driver()); + assertType('\'123\'', Cache::remember( + key: 'cache-key', + ttl: 60, + callback: static fn (): string => '123', + )); + assertType('123', Cache::rememberForever( + key: 'cache-key', + callback: static fn (): int => 123, + )); +} diff --git a/tests/PHPStan/Type/data/facades.php b/tests/PHPStan/Type/data/facades.php new file mode 100644 index 0000000000..52ccb5cf7b --- /dev/null +++ b/tests/PHPStan/Type/data/facades.php @@ -0,0 +1,88 @@ +lrange('some-key', 0, -1)); + assertType('array|Redis|RedisCluster|false', Redis::lrange('some-key', 0, -1)); + assertType('bool|Redis|RedisCluster', Redis::expire('foo', 3)); + + assertType('Hypervel\Database\Query\Builder', DB::query()); + assertType('int', DB::transactionLevel()); + + assertType('null', Queue::createPayloadUsing(function () { + })); + + assertType('Psr\Log\LoggerInterface', Log::getLogger()); + + // Hypervel: each disk is typed from the booted configuration, where the default disk is local. + assertType('Hypervel\Filesystem\LocalFilesystemAdapter', Storage::disk()); + assertType('Hypervel\Filesystem\LocalFilesystemAdapter', Storage::drive()); + // REMOVED: Storage::cloud() cases; Hypervel omits the default-cloud shortcut. + assertType('bool', Storage::disk()->deleteDirectory('foo')); + assertType('bool', Storage::drive()->deleteDirectory('foo')); + assertType('string|false', Storage::putFile('foo', 'foo/bar')); + // Hypervel: a static return forwarded through the facade describes its root, not the facade. + assertType('Hypervel\Filesystem\FilesystemManager', Storage::extend('custom', static fn (): null => null)); + // REMOVED: Redis::client(); pooled Redis requires a held connection, through Redis::withConnection(). + + assertType('string', DummyFacade::foo()); + assertType('int', DummyFacade::bar()); + // Hypervel: a facade without @method annotations takes its root's method from the booted application. + assertType('string', UndocumentedFacade::greet()); + // Hypervel: the facade calls its root from outside, so a non-public method is not one of its methods. + assertType('*ERROR*', UndocumentedFacade::whisper()); + + assertType('Hypervel\Http\Client\Response', Http::get('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::post('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::put('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::patch('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::delete('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::head('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::send('GET', 'https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::timeout(30)->get('https://example.test')); + assertType('Hypervel\Http\Client\Response', Http::withHeaders(['X-Foo' => 'bar'])->post('https://example.test')); + + assertType('int', Http::get('https://example.test')->getStatusCode()); + + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->get('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->post('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::async()->send('GET', 'https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::timeout(30)->async()->get('https://example.test')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::withHeaders(['X-Foo' => 'bar'])->async()->post('https://example.test')); + + assertType('Hypervel\Contracts\Cache\Repository', Cache::driver()); + assertType('\'123\'', Cache::remember( + key: 'cache-key', + ttl: 60, + callback: static fn (): string => '123', + )); + assertType('123', Cache::rememberForever( + key: 'cache-key', + callback: static fn (): int => 123, + )); +} diff --git a/tests/PHPStan/Type/data/form-request.php b/tests/PHPStan/Type/data/form-request.php new file mode 100644 index 0000000000..1e34f81e92 --- /dev/null +++ b/tests/PHPStan/Type/data/form-request.php @@ -0,0 +1,34 @@ +safe()); + // Hypervel: optional keys, because only() omits keys the validated input does not contain. + assertType('array{key?: mixed}', $request->safe(['key'])); + assertType('array{key?: mixed}|array{other?: mixed, another?: mixed}', $request->safe($condition ? ['key'] : ['other', 'another'])); + // Hypervel: a top-level numeric rule key, such as a list payload's '*.name', gives int keys. + assertType('array', $request->validated()); +} + +/** + * Hypervel: keys the extension cannot completely describe keep the declaration. + * + * @param list $keys + */ +function undescribedKeys(FormRequest $request, Validator $validator, array $keys, string $key): void +{ + assertType('array{key?: mixed}', $validator->safe(['key'])); + assertType('array', $request->safe(['user.name'])); + assertType('array', $request->safe(['items*'])); + assertType('array', $request->safe([$key])); + assertType('array', $request->safe(...[$keys])); +} diff --git a/tests/PHPStan/Type/data/forwarded-references.php b/tests/PHPStan/Type/data/forwarded-references.php new file mode 100644 index 0000000000..ad786c7f16 --- /dev/null +++ b/tests/PHPStan/Type/data/forwarded-references.php @@ -0,0 +1,92 @@ + + */ +class ReferenceBuilder extends Builder +{ + /** + * @param-out string $value + */ + public function mutate(mixed &$value): int + { + $value = 'changed'; + + return 1; + } + + /** + * @param-out string $value + * @return $this + */ + public function mutateFluently(mixed &$value): static + { + $value = 'changed'; + + return $this; + } +} + +class Item extends Model +{ + /** + * @return ReferenceBuilder + */ + public function newEloquentBuilder($query): ReferenceBuilder + { + return new ReferenceBuilder($query); + } + + /** + * @return HasMany + */ + public function children(): HasMany + { + return $this->hasMany(Item::class); + } + + /** + * @param Builder $query + * @param-out string $value + */ + #[Scope] + protected function counted(Builder $query, mixed &$value): void + { + $value = 'changed'; + } +} + +function test(Item $item): void +{ + // Model, relation and scope calls forward argument values, so the caller's variable keeps its type. + $value = 1; + + $item->mutate($value); + assertType('1', $value); + + Item::mutate($value); + assertType('1', $value); + + $item->children()->mutateFluently($value); + assertType('1', $value); + + Item::counted($value); + assertType('1', $value); + + // A builder's own method receives the variable. + $item->newQuery()->mutate($value); + assertType('string', $value); +} diff --git a/tests/PHPStan/Type/data/gate-facade.php b/tests/PHPStan/Type/data/gate-facade.php new file mode 100644 index 0000000000..40ae907169 --- /dev/null +++ b/tests/PHPStan/Type/data/gate-facade.php @@ -0,0 +1,15 @@ +', (new Account)->dispatchesEvents()); +} diff --git a/tests/PHPStan/Type/data/helpers.php b/tests/PHPStan/Type/data/helpers.php new file mode 100644 index 0000000000..990017ad89 --- /dev/null +++ b/tests/PHPStan/Type/data/helpers.php @@ -0,0 +1,318 @@ +guard('web')); + assertType('Hypervel\Auth\SessionGuard', auth('web')); + assertType('App\Admin|App\User|null', auth()->user()); + assertType('bool', auth()->check()); + assertType('App\User|null', auth()->guard('web')->user()); + assertType('App\User|null', auth('web')->user()); + assertType('App\Admin|null', auth()->guard('admin')->user()); + assertType('App\Admin|null', auth('admin')->user()); + assertType('int|string|null', auth()->id()); + assertType('int|string|null', auth('web')->id()); + assertType('int|string|null', auth('admin')->id()); + assertType('Hypervel\Contracts\Auth\Authenticatable|false', auth()->loginUsingId(1)); + assertType('null', auth()->login(new User())); + + // REMOVED: now() and today() assertions; their CarbonInterface return types come from source, and date-extension-l1{1,2}.php covers the omitted extension. + + assertType('Hypervel\Http\RedirectResponse', redirect('/')); + assertType('Hypervel\Routing\Redirector', redirect()); + + assertType('Hypervel\Http\Request', request()); + assertType('mixed', request('foo')); + // Hypervel: the source declares the input keys and values that request() returns for an array. + assertType('array', request(['foo', 'bar'])); + + assertType('\'ok\'|null', rescue(function () { + if (mt_rand(0, 1)) { + throw new Exception(); + } + + return 'ok'; + })); + + assertType('\'failed\'|\'ok\'', rescue(function () { + if (mt_rand(0, 1)) { + throw new Exception(); + } + + return 'ok'; + }, 'failed')); + + assertType('0|\'ok\'', rescue(function () { + if (mt_rand(0, 1)) { + throw new Exception(); + } + + return 'ok'; + }, function () { + return 0; + })); + + assertType('0|\'ok\'', rescue(function () { + if (mt_rand(0, 1)) { + throw new Exception(); + } + + return 'ok'; + }, function (Throwable $e) { + return 0; + })); + + assertType('\'failed\'|\'ok\'', rescue(function () { + if (mt_rand(0, 1)) { + throw new Exception(); + } + + return 'ok'; + }, 'failed', false)); + + assertType('Hypervel\Http\Response', response('foo')); + // Hypervel: the factory helpers return the booted binding, as app() does, rather than its contract. + assertType('Hypervel\Routing\ResponseFactory', response()); + + assertType('null', retry(3, function () { + })); + + assertType('5', retry(3, function (): int { + return 5; + })); + + assertType('App\User|null', retry(5, function (): ?User { + return User::first(); + }, 0, function (): bool { + return true; + })); + + assertType('false', retry(5, function (int $attempt): bool { + return false; + }, 0, function (Exception $e): bool { + return true; + })); + + assertType('Hypervel\Support\Stringable', str('foo')); + assertType('mixed', str()); + + // Hypervel: replacing changes the subject, so only its keys are kept. + assertType('string', Str::replace('foo', 'bar', 'Laravel')); + assertType('array<0|1, string>', Str::replace('foo', 'bar', ['Laravel', 'Framework'])); + assertType('array', Str::replace('foo', 'bar', collect(['Laravel', 'Framework']))); + + assertType('App\User', tap(new User(), function (User $user): void { + $user->name = 'Can Vural'; + $user->save(); + })); + + assertType('Hypervel\Support\HigherOrderTapProxy', tap(new User())); + assertType('App\User', tap(new User())->update(['name' => 'Taylor Otwell'])); + // Hypervel: validator() returns the Validator contract its factory declares, so contract methods replace valid() and addReplacers(). + assertType('Hypervel\Contracts\Validation\Validator', tap(validator([], []))->after(fn () => null)); + + assertType('string', url('/path')); + // Hypervel: the booted binding, as for response(). + assertType('Hypervel\Routing\UrlGenerator', url()); + + // Hypervel: the booted binding, as for response(). + assertType('Hypervel\Validation\Factory', validator()); + assertType('Hypervel\Contracts\Validation\Validator', validator(['foo' => 'bar'], ['foo' => 'required'])); + assertType('array', validator(['foo' => 'bar'], ['foo' => 'required'])->validated()); + + assertType('App\User|null', value(function (): ?User { + return User::first(); + })); + + assertType('5', value(5)); + assertType('int|string', value($intOrClosure)); + assertType('mixed', value($intOrClosureWithNoDocBlock)); + assertType('Helpers\InvokableValue', value(new InvokableValue())); + assertType("'strtoupper'", value('strtoupper')); + assertType("array{Helpers\InvokableValue, '__invoke'}", value([new InvokableValue(), '__invoke'])); + assertType('array{Closure(): 1}', value([fn () => 1])); + assertType('Hypervel\Support\Collection', value(collect([fn () => 1]))); + + assertType('array|null', transform(User::first(), fn (User $user) => $user->getAttributes())); + assertType('array', transform(User::sole(), fn (User $user) => $user->getAttributes())); + + // falls back to default if provided + // Hypervel: optional() is an Optional object, which is never blank, so a value that may be null shows the default. + assertType('1', transform(optional(), fn () => 1, 'default')); + assertType("1|'default'", transform(User::first(), fn () => 1, 'default')); + // default as callable + assertType('1', transform(optional(), fn () => 1, fn () => 'string')); + assertType("1|'string'", transform(User::first(), fn () => 1, fn () => 'string')); + + // non empty values + assertType('1', transform('filled', fn () => 1)); + assertType('1', transform(['filled'], fn () => 1)); + assertType('1', transform(new User(), fn () => 1)); + + // "empty" values + assertType('null', transform(null, fn () => 1)); + assertType('null', transform('', fn () => 1)); + assertType('null', transform([], fn () => 1)); + + // Hypervel: integers are never blank, so only null remains when the value is blank. + if (filled($value)) { + assertType('int', $value); + } else { + assertType('null', $value); + } + + if (blank($value)) { + assertType('null', $value); + } else { + assertType('int', $value); + } + + // Hypervel: a default does not decide the type of a value that is set. + assertType('bool|string|null', env('foo')); + assertType('bool|string|null', env('foo')); + assertType('120|bool|string|null', env('foo', 120)); + assertType('bool|string|null', env('foo', '')); + assertType('120|bool|string|null', env('foo', fn () => 120)); + assertType('bool|string|null', env('foo', null)); + assertType('bool|string|null', env('foo', fn () => null)); + + assertType('true', literal(true)); + assertType('int<0, 10>', literal(random_int(0,10))); + assertType("object{bar: 'bar'}&stdClass", literal(bar: "bar")); + assertType("object{foo: 22, bar: 'bar'}&stdClass", literal(foo: 22, bar: "bar")); + assertType("object{foo: int<0, 5>, bar: 'bar'}&stdClass", literal(foo: random_int(0,5), bar: "bar")); + assertType("object{}&stdClass", literal(new \stdClass)); + assertType("object{}&stdClass", literal()); + assertType("object{0: 'bar', 1: 'foo'}&stdClass", literal('bar', 'foo')); + assertType("object{0: 4, bar: 'foo'}&stdClass", literal(4, bar:'foo')); + assertType("App\User", literal(new User())); + assertType("array{foo: 22, bar: 'bar'}", literal(['foo' => 22, 'bar' => "bar"])); + assertType("object{0: 5, 1: 7}&stdClass", literal(...[5,7])); + assertType("object{foo: 22, bar: 'bar'}&stdClass", literal(...['foo' => 22, 'bar' => "bar"])); +} + +/** + * Hypervel: helpers decide by the arguments given, and an unpacked argument is the list holding the values. + * + * @param list<\Closure(): 1> $closures + * @param list $arguments + */ +function testHypervelArguments(array $closures, array $arguments): void +{ + assertType('Hypervel\Support\HigherOrderTapProxy', tap(new User(), null)); + assertType('Hypervel\Support\Stringable', str(null)); + assertType('1', value(...$closures)); + assertType('Hypervel\Support\Collection', collect(...[[1, 2]])); + assertType('mixed', literal(...$arguments)); + // An unpacked list may be empty, so these may return the factory or the result. + assertType('Hypervel\Http\Response|Hypervel\Routing\ResponseFactory', response(...$arguments)); + assertType('mixed', str(...$arguments)); + assertType('Hypervel\Contracts\Validation\Validator|Hypervel\Validation\Factory', validator(...$arguments)); + assertType('Hypervel\Contracts\View\View|Hypervel\View\Factory', view(...$arguments)); +} + +/** + * The factory helpers resolve their factory from the container, so it is the booted binding. + * + * @param list $strings + */ +function testBoundHelpers(ResponseFactory $factory, ?string $path, ?string $message, array $arguments, array $strings): void +{ + assertType('string', response()->caps('foo')); + // A contract value does not gain the macros of its current binding. + assertType('*ERROR*', $factory->caps('foo')); + + assertType('string', url()->full()); + assertType('Hypervel\Routing\UrlGenerator', url(null)); + assertType('Hypervel\Routing\UrlGenerator|string', url($path)); + assertType('Hypervel\Routing\UrlGenerator', url(parameters: ['foo' => 'bar'])); + assertType('Hypervel\Routing\UrlGenerator|string', url(...$arguments)); + + assertType('Hypervel\Log\LogManager', logger()); + assertType('Hypervel\Log\LogManager', logger(null)); + assertType('null', logger('message')); + assertType('Hypervel\Log\LogManager|null', logger($message)); + + // A list of strings may be empty, which leaves the path or message at its null default. + assertType('Hypervel\Routing\UrlGenerator|string', url(...$strings)); + assertType('Hypervel\Log\LogManager|null', logger(...$strings)); +} + +/** + * Hypervel: literal() receives integer keys by position and string keys by name. + * + * @param array{0: 'a', 1?: 'b'} $maybeTwo + * @param array{5?: 'a', 9: 'b'} $maybeFirst + * @param array{0?: 'a', 1?: 'b', 2?: 'c', 3?: 'd', 4?: 'e', 5?: 'f', 6?: 'g', 7?: 'h', 8?: 'i', 9?: 'j', 10?: 'k'} $maybeAny + * @param array{x: 'a', y?: 'b'} $maybeNamed + * @param array{x: 'a', 5?: 'b'} $namedThenMaybe + * @param array{x: 'a', ...} $unsealed + */ +function testLiteralUnpacking(array $maybeTwo, array $maybeFirst, array $maybeAny, array $maybeNamed, array $namedThenMaybe, array $unsealed): void +{ + assertType("object{0: 'a', 1: 'b'}&stdClass", literal(...[5 => 'a', 9 => 'b'])); + assertType("'a'", literal(...[5 => 'a'])); + assertType("object{0: 'a', x: 'b'}&stdClass", literal(...[5 => 'a', 'x' => 'b'])); + assertType('mixed', literal(...$unsealed)); + + // An optional argument moves the positions after it, and any single positional argument may be returned. + assertType("'a'|(object{0: 'a', 1?: 'b'}&stdClass)", literal(...$maybeTwo)); + assertType("'b'|(object{}&stdClass)", literal(...$maybeFirst)); + assertType("'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|(object{}&stdClass)", literal(...$maybeAny)); + assertType("object{x: 'a', y?: 'b'}&stdClass", literal(...$maybeNamed)); + + // PHP throws when a positional argument follows a named one. + assertType('*NEVER*', literal(...['x' => 'a', 5 => 'b'])); + assertType("object{x: 'a'}&stdClass", literal(...$namedThenMaybe)); +} + +/** + * @param array{loo:'loo'}|array{foo:'foo',bar:'baa'} $parameter + */ +function testUnion($parameter): int { + assertType("(object{foo: 'foo', bar: 'baa'}&stdClass)|(object{loo: 'loo'}&stdClass)", literal(...$parameter)); + + return 0; +}; + +/** + * @param callable(): int $intCallable + */ +function testCallable(callable $intCallable): void +{ + assertType('(callable(): int)|int', value($intCallable)); +} + +class InvokableValue +{ + public function __invoke(): int + { + return 1; + } +} diff --git a/tests/PHPStan/Type/data/higher-order-collection-proxies.php b/tests/PHPStan/Type/data/higher-order-collection-proxies.php new file mode 100644 index 0000000000..0c08c57497 --- /dev/null +++ b/tests/PHPStan/Type/data/higher-order-collection-proxies.php @@ -0,0 +1,111 @@ +points; + } + + public function ratio(): float + { + return $this->ratio; + } +} + +/** + * @param Collection $importers + * @param Collection $scores + */ +function test(Collection $importers, Collection $scores): void +{ + // Averages may be integers, and an empty collection has no average, maximum or minimum. + assertType('float|int|null', $scores->avg->points()); + assertType('float|int|null', $scores->average->ratio); + assertType('int|null', $scores->max->points()); + assertType('float|null', $scores->min->ratio); + + // Proxies return what the collection method returns, and an integer sum can overflow to a float. + assertType('float|int', $scores->sum->points); + assertType('float|int', $scores->sum->ratio()); + + // Upstream has no proxies for these. + assertType('bool', $importers->doesntContain->isImported()); + assertType('bool', $importers->hasMany->isImported()); + assertType('bool', $importers->hasSole->isImported); + assertType('App\Importer|null', $importers->last->isImported()); + assertType('App\Importer', $importers->sole->isImported()); + assertType('float|null', $importers->percentage->isImported()); +} + +class Seeder +{ + public static function run(): bool + { + return true; + } + + public function name(): string + { + return ''; + } +} + +/** + * Hypervel: proxies keep the collection's keys and class where the collection method does. + * + * @param Collection $scores + * @param LazyCollection $lazyScores + * @param EloquentCollection $users + * @param Collection $rows + * @param Collection> $seeders + */ +function keysAndClasses(Collection $scores, LazyCollection $lazyScores, EloquentCollection $users, Collection $rows, Collection $seeders): void +{ + assertType('Hypervel\Support\Collection', $scores->map->points); + assertType('Hypervel\Support\Collection', $scores->filter->points()); + assertType('Hypervel\Support\Collection', $scores->sortBy->ratio); + assertType('Hypervel\Support\Collection, Hypervel\Support\Collection>', $scores->partition->points); + assertType('Hypervel\Support\Collection>', $scores->groupBy->points); + assertType('Hypervel\Support\Collection', $scores->keyBy->points); + + assertType('Hypervel\Support\LazyCollection', $lazyScores->map->ratio()); + assertType('Hypervel\Support\LazyCollection', $lazyScores->reject->points); + + assertType('Hypervel\Support\Collection', $users->map->getAllCapsName()); + assertType('Hypervel\Support\Collection, Hypervel\Database\Eloquent\Collection>', $users->partition->isActive()); + + // The proxy reads array items by key, and calls the static methods of class-string items. + assertType('Hypervel\Support\Collection', $rows->map->active); + assertType('Hypervel\Support\Collection', $rows->filter->active); + assertType('Hypervel\Support\Collection', $seeders->map->run()); +} + +/** + * @template TKey of array-key + * @template TValue of Score + * + * @extends Collection + */ +class Scores extends Collection +{ + public function points(): void + { + // A static collection type means the collection, not the proxy. + assertType('HigherOrderCollectionProxies\Scores', $this->map->points()); + } +} diff --git a/tests/PHPStan/Type/data/higher-order-collection-proxy-methods.php b/tests/PHPStan/Type/data/higher-order-collection-proxy-methods.php new file mode 100644 index 0000000000..6a2b947fec --- /dev/null +++ b/tests/PHPStan/Type/data/higher-order-collection-proxy-methods.php @@ -0,0 +1,102 @@ + $users + * @param SupportCollection $collection + * @param SupportCollection $supportCollectionWithModels + * @param OnlyValueGenericCollection $onlyValueGenericCollection + * @param TransactionCollection $transactionCollection + */ +function test( + EloquentCollection $users, + User $user, + SupportCollection $collection, + SupportCollection $supportCollectionWithModels, + NonGenericCollection $nonGenericCollection, + OnlyValueGenericCollection $onlyValueGenericCollection, + TransactionCollection $transactionCollection, +): void { + // Unlike upstream, these follow Hypervel's collection types: averages may be integers, an empty + // collection has no maximum or minimum, an integer sum can overflow to a float, map() keeps the + // collection's keys, and partition() returns the base collection keyed 0 and 1. + assertType('float|int', $users->avg->id() + $users->average->id()); + assertType('bool', $users->contains->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->each->delete()); + assertType('bool', $users->every->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->filter->isActive()); + assertType('App\User|null', $users->first->isActive()); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $users->flatMap->isActive()); + assertType('Hypervel\Database\Eloquent\Collection>', $users->groupBy->isActive()); + assertType('Hypervel\Database\Eloquent\Collection>', $users->groupBy->getAllCapsName()); + assertType('Hypervel\Database\Eloquent\Collection<(int|string), App\User>', $users->keyBy->isActive()); + assertType('Hypervel\Support\Collection', $users->map->isActive()); + assertType('Hypervel\Support\Collection>', $users->map->accounts()); + assertType('Hypervel\Support\Collection', $users->map->id()); +// assertType('array', $user->accounts->map->getAttributes()->all()); + assertType('int|null', $users->max->id()); + assertType('int|null', $users->min->id()); + assertType('Hypervel\Support\Collection, Hypervel\Database\Eloquent\Collection>', $users->partition->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->reject->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->skipUntil->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->skipWhile->isActive()); + assertType('float|int', $users->sum->id()); + assertType('Hypervel\Database\Eloquent\Collection', $users->takeUntil->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->takeWhile->isActive()); + assertType('Hypervel\Database\Eloquent\Collection', $users->unique->isActive()); + assertType('Hypervel\Support\Collection', $collection->map->import()); + assertType('Hypervel\Support\Collection', $collection->each->import()); + assertType('Hypervel\Support\Collection', $collection->filter->isImported()); + + // Unlike upstream, ids read int on the SQLite test connection, dates read CarbonInterface, and + // User::email() is an accessor Eloquent applies, so email reads as its untyped callback. + assertType('float|int', $users->avg->id + $users->average->id); + assertType('bool', $users->contains->email); + assertType('bool', $users->every->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->each->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->filter->email); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $users->flatMap->email); + assertType('App\User|null', $users->first->email); + assertType('Hypervel\Database\Eloquent\Collection<(int|string), Hypervel\Database\Eloquent\Collection>', $users->groupBy->email); + assertType('Hypervel\Database\Eloquent\Collection>', $users->groupBy->id); + assertType('Hypervel\Database\Eloquent\Collection<(int|string), App\User>', $users->keyBy->email); + assertType('Hypervel\Support\Collection', $users->map->email); + assertType('Hypervel\Support\Collection', $users->map->id); + assertType('Hypervel\Support\Collection<(int|string), int<0, max>>', $users->groupBy('status')->map->count()); + assertType('Hypervel\Support\Collection', $users->map->created_at); + assertType('mixed', $users->max->email); + assertType('int|null', $users->max->id); + assertType('mixed', $users->min->email); + assertType('int|null', $users->min->id); + assertType('Hypervel\Support\Collection, Hypervel\Database\Eloquent\Collection>', $users->partition->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->reject->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->skipUntil->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->skipWhile->email); + assertType('float|int', $users->sum->id); + assertType('float|int', $users->sum->unsigned_integer); + assertType('Hypervel\Database\Eloquent\Collection', $users->takeUntil->email); + assertType('Hypervel\Database\Eloquent\Collection', $users->takeWhile->email); + + assertType('Hypervel\Support\Collection', $supportCollectionWithModels->reject->isActive()); + + // Custom collections + assertType('App\NonGenericCollection', $nonGenericCollection->each->count()); + assertType('App\NonGenericCollection', $nonGenericCollection->reject->id); + assertType('App\OnlyValueGenericCollection', $onlyValueGenericCollection->each->count()); + assertType('App\OnlyValueGenericCollection', $onlyValueGenericCollection->reject->id); + assertType('App\TransactionCollection', $transactionCollection->each->count()); + assertType('App\TransactionCollection', $transactionCollection->reject->id); +} diff --git a/tests/PHPStan/Type/data/inherited-method-templates.php b/tests/PHPStan/Type/data/inherited-method-templates.php new file mode 100644 index 0000000000..716ebb7e77 --- /dev/null +++ b/tests/PHPStan/Type/data/inherited-method-templates.php @@ -0,0 +1,29 @@ + $eager + * @param LazyCollection $lazy + */ +function inheritedTemplates(Collection $eager, LazyCollection $lazy, Hub $hub, mixed $unknown, string $name): void +{ + // Hypervel: these overrides repeat their parent's method templates, which PHPStan 2.3.5 + // otherwise leaves unresolved when the arguments do not infer them. + assertType('Hypervel\Support\Collection<(int|string), string>', $eager->keyBy(fn (string $value) => $unknown)); + assertType('Hypervel\Support\LazyCollection<(int|string), string>', $lazy->keyBy(fn (string $value) => $unknown)); + assertType('Hypervel\Support\LazyCollection<(int|string), array>', $lazy->mapToDictionary(fn (string $value) => $unknown)); + assertType('Hypervel\Support\LazyCollection>', $lazy->crossJoin($unknown)); + assertType('Hypervel\Support\LazyCollection', $lazy->merge($unknown)); + assertType('Hypervel\Support\LazyCollection', $lazy->mergeRecursive($unknown)); + assertType('Sentry\Integration\IntegrationInterface|null', $hub->getIntegration($name)); + assertType('mixed', $hub->withScope($unknown)); +} diff --git a/tests/PHPStan/Type/data/macros-php-81.php b/tests/PHPStan/Type/data/macros-php-81.php new file mode 100644 index 0000000000..8c2dd34b36 --- /dev/null +++ b/tests/PHPStan/Type/data/macros-php-81.php @@ -0,0 +1,12 @@ +accounts()->globalCustomMacro(b: 99)); + + // Builder::__call() runs a global macro before a scope of the same name. + assertType('string', ModelWithMacroNamedScope::globalCustomMacro(b: 99)); + assertType('string', ModelWithMacroNamedScope::query()->globalCustomMacro(b: 99)); + + // Unlike upstream, Eloquent discards a forwarded query macro's result and returns the builder, which a relation + // returns as itself. Builder::__callStatic() runs only the builder's own global macros, so the query macro is + // called on a builder instance here; upstream's static PostBuilder::globalCustomDatabaseQueryMacro() is an error. + assertType('string', QueryBuilder::globalCustomDatabaseQueryMacro(b: 99)); + assertType('App\PostBuilder', Post::globalCustomDatabaseQueryMacro(b: 99)); + assertType('App\PostBuilder', Post::query()->globalCustomDatabaseQueryMacro(b: 99)); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', User::first()->accounts()->globalCustomDatabaseQueryMacro(b: 99)); + + assertType('int', Route::facadeMacro()); + // Hypervel: this app's untyped passport guard leaves the current guard unknown, so no guard macro is + // available through Auth. auth-guards.php covers typed guards. + assertType('*ERROR*', Auth::sessionGuardMacro()); + assertType('*ERROR*', Auth::requestGuardMacro()); + + assertType('string', collect([])->customCollectionMacro()); + assertType('string', Collection::customCollectionMacro()); + + assertType('string', collect([])->customCollectionMacroString()); + assertType('string', Collection::customCollectionMacroString()); + + assertType('string', Str::trimMacro('')); + assertType('string', Str::asciiAliasMacro('')); + + assertType('mixed', Cache::rememberIf(true, 'key', 60, fn () => 'value')); + + assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); + + Request::macro('foo', function () { + assertType(Request::class, $this); + }); + + Foo::macro('foo', function () { + assertType(Foo::class, $this); + }); +} + +class Foo +{ + use Macroable; +} + +// Hypervel: Builder::__callStatic() binds a global macro to the builder and registers a mixin's methods, returning +// null. Builder::__call() calls a local macro unbound, and an instance mixin() reaches the query's, as a model's does. +function builderRegistration(Post $post): void +{ + Builder::macro('globalRegistration', function () { + assertType('Hypervel\Database\Eloquent\Builder', $this); + }); + + PostBuilder::macro('postRegistration', function () { + assertType('App\PostBuilder', $this); + }); + + assertType('null', Builder::mixin(new stdClass)); + assertType('App\PostBuilder', Post::query()->mixin(new stdClass)); + assertType('App\PostBuilder', Post::mixin(new stdClass)); + assertType('App\PostBuilder', $post->mixin(new stdClass)); +} + +class LocalMacroProvider +{ + /** + * Register a local macro, which keeps this provider as its $this. + * + * @param Builder $query + */ + public function register(Builder $query): void + { + $query->macro('localRegistration', function (Builder $builder) { + assertType('$this(Macros\LocalMacroProvider)', $this); + }); + } +} + +// Hypervel: @method tags type Request's macros, and MacroThrowTypeExtension adds the macros' exceptions. +function taggedMacros(Request $request, NativeValidateRequest $nativeRequest, CustomCallHandler $handler): void +{ + assertType('array', $request->validate([])); + assertType('array', $request->validateWithBag('bag', [])); + assertType('array', RequestFacade::validate([])); + assertType('bool', $request->hasValidSignature()); + assertType('bool', $request->hasValidRelativeSignature()); + + try { + $request->validate([]); + } catch (ValidationException $e) { + $instance = 'instance'; + } + + assertVariableCertainty(TrinaryLogic::createMaybe(), $instance); + + try { + RequestFacade::validateWithBag('bag', []); + } catch (ValidationException $e) { + $facade = 'facade'; + } + + assertVariableCertainty(TrinaryLogic::createMaybe(), $facade); + + // A native method overriding a macro keeps its own exceptions. + try { + $nativeRequest->validate([]); + } catch (ValidationException $e) { + $native = 'native'; + } + + assertVariableCertainty(TrinaryLogic::createNo(), $native); + + // A magic handler keeps its declared exception when no macro replaces it. + try { + $handler->handled(); + } catch (CustomCallException $e) { + $handled = 'handled'; + } + + assertVariableCertainty(TrinaryLogic::createMaybe(), $handled); +} + +// Hypervel: Macroable calls invokable macros directly, both ways, and passes every macro its argument values. +function callableMacros(MacroHost $host): void +{ + $value = 1; + + assertType('int', $host->invokableMacro($value)); + assertType('int', MacroHost::invokableMacro($value)); + assertType('1', $value); + + $host->referenceMacro($value); + assertType('1', $value); +} + +class NativeValidateRequest extends Request +{ + /** + * Validate the request natively instead of through the macro. + * + * @throws RuntimeException + */ + public function validate(array $rules): array + { + throw new RuntimeException; + } +} + +/** + * @method int handled() + */ +class CustomCallHandler +{ + /** + * Handle a documented magic method. + * + * @throws CustomCallException + */ + public function __call(string $method, array $parameters): mixed + { + throw new CustomCallException; + } +} + +class CustomCallException extends RuntimeException +{ +} + +class ModelWithMacroNamedScope extends Model +{ + /** @param Builder $query */ + public function scopeGlobalCustomMacro(Builder $query): void + { + } +} + +/** + * A builder that redeclares the registry keeps its own macros, without the base builder's. + * + * @template TModel of Model + * + * @extends Builder + */ +class IsolatedMacroBuilder extends Builder +{ + protected static array $macros = []; +} + +function isolatedMacroRegistry(): void +{ + assertType('*ERROR*', IsolatedMacroBuilder::globalCustomMacro()); +} diff --git a/tests/PHPStan/Type/data/managers.php b/tests/PHPStan/Type/data/managers.php new file mode 100644 index 0000000000..2ac188bfc3 --- /dev/null +++ b/tests/PHPStan/Type/data/managers.php @@ -0,0 +1,107 @@ +export([])); + assertType('Hypervel\Tests\PHPStan\Fixtures\Managers\CsvDriver', $manager->withHeader()); + + // The manager's @method tag wins over the driver's signature. + assertType('int', $manager->count()); + + // __call() runs in the manager, so it does not reach the driver's protected methods. + assertType('*ERROR*', $manager->format()); + + // The driver receives the argument's value, not the caller's variable. + $buffer = 'rows'; + $manager->exportInto($buffer); + assertType("'rows'", $buffer); +} + +function creatorsAndOverrides( + ExtendedExportManager $extended, + WrappedCreatorManager $wrappedCreator, + WrappingManager $wrapping, + SelectingManager $selecting, +): void { + // A registered creator replaces the creator method for the same driver. + assertType('string', $extended->toJson()); + assertType('*ERROR*', $extended->export([])); + + // An override's declared return types the driver it selects or creates. + assertType('string', $wrappedCreator->toJson()); + assertType('string', $wrapping->toJson()); + assertType('*ERROR*', $wrapping->export([])); + assertType('string', $selecting->toJson()); +} + +function unforwardedCalls( + MacroableExportManager $macroable, + UnconfiguredManager $unconfigured, + ResolvedExportManager $resolved, + ResolvedChildDriverManager $resolvedChild, + RenamingManager $renaming, +): void { + // Macroable's __call() only runs macros. + assertType('*ERROR*', $macroable->export([])); + + // Without a configured default driver, nothing is forwarded. + assertType('*ERROR*', $unconfigured->export([])); + + // Manager::driver() keeps returning the CSV driver it created before the JSON creator was registered. + assertType('*ERROR*', $resolved->toJson()); + + // The cached plain CSV driver is not the subclass the later creator declares. + assertType('*ERROR*', $resolvedChild->exportTabbed()); + + // Analysis never calls an overriding getCreatorMethod(), so it cannot tell which creator runs. + assertType('*ERROR*', $renaming->export([])); +} + +function boundManagers(BoundExportManager $bound): void +{ + // The container binds the manager to a subclass, whose own selection and creator decide the driver. + assertType('string', $bound->toJson()); + assertType('*ERROR*', $bound->export([])); +} + +/** + * @param GenericManager $csv + * @param GenericManager $json + */ +function genericManagers(GenericManager $csv, GenericManager $json): void +{ + // Each specialization has its own declared driver. + assertType('string', $csv->export([])); + assertType('string', $json->toJson()); + assertType('*ERROR*', $json->export([])); +} + +function maintenanceMode(MaintenanceModeManager $manager): void +{ + // Every maintenance driver implements the contract the manager declares as its mixin. + assertType('bool', $manager->active()); + assertType('array', MaintenanceMode::data()); +} diff --git a/tests/PHPStan/Type/data/migrator.php b/tests/PHPStan/Type/data/migrator.php new file mode 100644 index 0000000000..d9cb75b3f2 --- /dev/null +++ b/tests/PHPStan/Type/data/migrator.php @@ -0,0 +1,11 @@ +usingConnection(null, static fn () => 42)); +} diff --git a/tests/PHPStan/Type/data/model-casts-parsing-trait.php b/tests/PHPStan/Type/data/model-casts-parsing-trait.php new file mode 100644 index 0000000000..e736da212e --- /dev/null +++ b/tests/PHPStan/Type/data/model-casts-parsing-trait.php @@ -0,0 +1,18 @@ + 'float', + ]; + } +} diff --git a/tests/PHPStan/Type/data/model-casts-parsing.php b/tests/PHPStan/Type/data/model-casts-parsing.php new file mode 100644 index 0000000000..0975234171 --- /dev/null +++ b/tests/PHPStan/Type/data/model-casts-parsing.php @@ -0,0 +1,408 @@ +integer); + assertType(Stringable::class, $model->string); +} + +class ModelWithCasts extends Model +{ + public function foo(): int + { + return 5; + } + + public function casts(): array + { + return [ + 'integer' => 'bool', + 'string' => AsStringable::class.':argument', + ]; + } +} + +function testRegressions( + ChildModel $child, + SecondModel $second, + TraitModel $trait, + NestedClosureModel $nestedClosure, + HelperModel $helpers, + ConstructedCastModel $constructed, + MultipleReturnsModel $multipleReturns, + OpenShapeModel $openShape, + UnknownCastsModel $unknown, + MissingTableModel $missingTable, + PropertyArrayCastModel $propertyArrays, + VirtualCastModel $virtual, +): void { + // self:: in an inherited casts() refers to the class declaring it. + assertType('bool', $child->integer); + + // Each model reads its own casts(), even when another model in the file declares one first. + assertType('float', $second->integer); + + assertType('float', $trait->integer); + + // A nested closure's return is not the method's. + assertType('float', $nestedClosure->integer); + + assertType('Hypervel\Support\Collection<(int|string), App\Casts\BackedEnumeration>|null', $helpers->collection); + assertType('(Hypervel\Support\Collection<(int|string), mixed>|null)', $helpers->nullable_collection); + // The selected collection class is kept, unless a mapper may return another. + assertType('(ModelCastsParsing\OptionCollection<(int|string), mixed>|null)', $helpers->object); + assertType('(Hypervel\Support\Collection<(int|string), mixed>|null)', $helpers->bool); + assertType('(Hypervel\Database\Eloquent\Casts\ArrayObject|null)', $helpers->json); + assertType('Hypervel\Support\Collection<(int|string), App\Casts\BackedEnumeration>|null', $helpers->array); + assertType('mixed', $helpers->string); + assertType('float', $helpers->float); + $helpers->string = 'value'; + assertType('mixed', $helpers->string); + + // The cast is read from the declared return type, without constructing the model or calling casts(). + assertType(Stringable::class, $constructed->string); + + // A method with several returns is read from its declared return type. + assertType('bool', $multipleReturns->integer); + assertType('mixed', $multipleReturns->string); + assertType('float', $multipleReturns->float); + + // An open shape leaves the other attributes' casts unknown. + assertType('bool', $openShape->integer); + assertType('mixed', $openShape->float); + + assertType('mixed', $unknown->integer); + assertType('mixed', $unknown->float); + // The cast may be a class cast, which serializes before the accessor. + assertType('mixed', $unknown->toArray()['real']); + + // A key without a known column is still cast. + assertType('string', $missingTable->id); + + // Array definitions in the $casts property are normalized as initializeHasAttributes() does. + assertType('(Hypervel\Database\Eloquent\Casts\ArrayObject|null)', $propertyArrays->json); + assertType('numeric-string', $propertyArrays->decimal); + + // A cast key is an attribute without a column of its own. + assertType('ModelCastsParsing\Dimensions', $virtual->dimensions); +} + +function testCasters(CastableModel $castable, MissingTableModel $missingTable, VirtualCastModel $virtual): void +{ + // castUsing() selects one caster by class name. + assertType('ModelCastsParsing\Dimensions', $castable->real); + // A castUsing() that may return several casters leaves the type unknown. + assertType('mixed', $castable->float); + + // An attribute without a known column may be null. + assertType('int|null', $missingTable->count); + assertType('int|null', $virtual->quantity); +} + +function testHelpers(): void +{ + assertType("'Hypervel\\\\Database\\\\Eloquent\\\\Casts\\\\AsEnumCollection:App\\\\Casts\\\\BackedEnumeration'", AsEnumCollection::of(BackedEnumeration::class)); + assertType("'Hypervel\\\\Database\\\\Eloquent\\\\Casts\\\\AsCollection:,,nullable'", AsCollection::nullable(null)); + assertType("'Hypervel\\\\Database\\\\Eloquent\\\\Casts\\\\AsCollection:,App\\\\Casts\\\\BackedEnumeration@from'", AsCollection::of([BackedEnumeration::class, 'from'])); + assertType('string', AsCollection::of(['not', 'callable'])); + assertType('string', AsEnumCollection::of(123)); + assertType('string', CollectionCast::of(BackedEnumeration::class)); +} + +class CollectionCast extends AsCollection +{ +} + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class OptionCollection extends Collection +{ +} + +class ParentModel extends Model +{ + public const string CAST = 'bool'; + + protected ?string $table = 'model_with_casts'; + + protected function casts(): array + { + return [ + 'integer' => self::CAST, + ]; + } +} + +class ChildModel extends ParentModel +{ + public const string CAST = 'array'; +} + +class SecondModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected function casts(): array + { + return [ + 'integer' => 'float', + ]; + } +} + +class TraitModel extends Model +{ + use CastsIntegerAsFloat; + + protected ?string $table = 'model_with_casts'; +} + +class NestedClosureModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected function casts(): array + { + $unused = static function (): array { + return ['integer' => 'bool']; + }; + + return [ + 'integer' => 'float', + ]; + } +} + +class HelperModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected function casts(): array + { + $cast = 'array'; + + return [ + 'collection' => AsEnumCollection::of(BackedEnumeration::class), + 'nullable_collection' => AsCollection::using(Collection::class), + 'object' => AsCollection::using(OptionCollection::class), + 'bool' => AsCollection::using(OptionCollection::class, Dimensions::class), + 'json' => AsArrayObject::nullable(), + 'array' => [AsEnumCollection::class, BackedEnumeration::class], + 'string' => $cast, + ]; + } +} + +class ConstructedCastModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + private string $castClass; + + public function __construct(array $attributes = []) + { + $this->castClass = AsStringable::class; + + parent::__construct($attributes); + } + + /** + * @return array{string: 'Hypervel\\Database\\Eloquent\\Casts\\AsStringable'} + */ + protected function casts(): array + { + return [ + 'string' => $this->castClass, + ]; + } +} + +class MultipleReturnsModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + /** + * @return array{integer: 'bool', string?: 'array'} + */ + protected function casts(): array + { + if ($this->exists) { + return ['integer' => 'bool', 'string' => 'array']; + } + + return ['integer' => 'bool']; + } +} + +class OpenShapeModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + /** + * @return array{integer: 'bool', ...} + */ + protected function casts(): array + { + return [...parent::casts(), 'integer' => 'bool']; + } +} + +class UnknownCastsModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected function casts(): array + { + return array_merge(parent::casts(), ['integer' => 'bool']); + } + + /** + * @return Attribute + */ + protected function real(): Attribute + { + return Attribute::get(fn (float $value): string => (string) $value); + } +} + +class MissingTableModel extends Model +{ + protected ?string $table = 'missing_table'; + + protected function casts(): array + { + return [ + 'id' => 'string', + 'count' => 'int', + ]; + } +} + +class PropertyArrayCastModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected array $casts = [ + 'json' => [AsArrayObject::class], + 'decimal' => ['decimal', 1], + ]; +} + +class VirtualCastModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected array $casts = [ + 'dimensions' => DimensionsCast::class, + 'quantity' => 'int', + ]; +} + +class CastableModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected array $casts = [ + 'real' => DimensionsCastable::class, + 'float' => AmbiguousCastable::class, + ]; +} + +class DimensionsCastable implements Castable +{ + /** + * @return class-string + */ + public static function castUsing(array $arguments): string + { + return DimensionsCast::class; + } +} + +class AmbiguousCastable implements Castable +{ + /** + * @return DimensionsCast|StringableCast + */ + public static function castUsing(array $arguments): CastsAttributes + { + return $arguments === [] ? new DimensionsCast : new StringableCast; + } +} + +/** + * @implements CastsAttributes + */ +class StringableCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): Stringable + { + return new Stringable((string) $value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return (string) $value; + } +} + +/** + * @implements CastsAttributes + */ +class DimensionsCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): Dimensions + { + return new Dimensions($attributes['real'], $attributes['float']); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + return ['real' => $value->width, 'float' => $value->height]; + } +} + +class Dimensions +{ + public function __construct(public float $width, public float $height) + { + } +} + +class UppercaseCastsModel extends Model +{ + protected ?string $table = 'model_with_casts'; + + protected array $casts = [ + 'string' => 'BOOLEAN', + 'array' => 'ENCRYPTED:ARRAY', + 'datetime' => 'DATETIME:Y-m-d', + ]; +} + +// Primitive casts match in lowercase, as getCastType() normalizes them, but a date cast with arguments matches only as written. +function uppercaseCasts(UppercaseCastsModel $model): void +{ + assertType('bool', $model->string); + assertType('array', $model->array); + assertType('mixed', $model->datetime); +} diff --git a/tests/PHPStan/Type/data/model-collections-l11-28.php b/tests/PHPStan/Type/data/model-collections-l11-28.php new file mode 100644 index 0000000000..c33cbd25be --- /dev/null +++ b/tests/PHPStan/Type/data/model-collections-l11-28.php @@ -0,0 +1,59 @@ +paginate()->getCollection()); + assertType('ModelCollectionsL1128\UserCollection', User::query()->simplePaginate()->getCollection()); + assertType('ModelCollectionsL1128\UserCollection', User::query()->cursorPaginate()->getCollection()); + assertType('ModelCollectionsL1128\UserCollection', User::query()->where('id', '>', 1)->get()); + assertType('ModelCollectionsL1128\TeamCollection', Team::all()); + assertType('ModelCollectionsL1128\PostCollection', Post::all()); +} + +#[CollectedBy(UserCollection::class)] +class User extends Model +{ + +} + +#[CollectedBy(UserCollection::class)] +class Team extends Model +{ + /** @param array $models */ + public function newCollection(array $models = []): TeamCollection + { + return new TeamCollection($models); + } +} + +#[CollectedBy('ModelCollectionsL1128\PostCollection')] +class Post extends Model +{ + +} + +/** @extends Collection */ +class UserCollection extends Collection +{ + +} + +/** @extends Collection */ +class TeamCollection extends Collection +{ + +} + +/** @extends Collection */ +class PostCollection extends Collection +{ + +} diff --git a/tests/PHPStan/Type/data/model-collections-l13-4.php b/tests/PHPStan/Type/data/model-collections-l13-4.php new file mode 100644 index 0000000000..f82cff27a1 --- /dev/null +++ b/tests/PHPStan/Type/data/model-collections-l13-4.php @@ -0,0 +1,142 @@ +', User::all()); + assertType('ModelCollectionsL134\BaseCollection', User::query()->where('id', '>', 1)->get()); + assertType('ModelCollectionsL134\BaseCollection', Admin::all()); + assertType('ModelCollectionsL134\TeamCollection', Team::all()); + // Hypervel: the attribute is instantiated, so its named argument applies to the model and its child. + assertType('ModelCollectionsL134\TeamCollection', NamedArgumentParent::all()); + assertType('ModelCollectionsL134\TeamCollection', NamedArgumentChild::all()); + assertType('ModelCollectionsL134\StringCollection', StringChild::all()); + assertType('ModelCollectionsL134\StringCollection', LeadingBackslashString::all()); + assertType('ModelCollectionsL134\BaseCollection', TraitParentChild::all()); + assertType('ModelCollectionsL134\BaseCollection', TraitChild::all()); + // Hypervel: without the attribute, HasCollection uses the static $collectionClass, which a child inherits. + assertType('ModelCollectionsL134\StringCollection', ClassCollectionParent::all()); + assertType('ModelCollectionsL134\StringCollection', ClassCollectionChild::all()); + assertType('ModelCollectionsL134\BaseCollection', AttributeOverClassCollection::all()); +} + +#[CollectedBy(BaseCollection::class)] +abstract class BaseModel extends Model +{ + +} + +class User extends BaseModel +{ + +} + +class Admin extends User +{ + +} + +class Team extends BaseModel +{ + /** @param array $models */ + public function newCollection(array $models = []): TeamCollection + { + return new TeamCollection($models); + } +} + +/** Laravel only reads a positional argument; Hypervel instantiates the attribute, so this one applies. */ +#[CollectedBy(collectionClass: TeamCollection::class)] +class NamedArgumentParent extends BaseModel +{ + +} + +class NamedArgumentChild extends NamedArgumentParent +{ + +} + +#[CollectedBy('ModelCollectionsL134\StringCollection')] +class StringParent extends Model +{ + +} + +class StringChild extends StringParent +{ + +} + +#[CollectedBy('\ModelCollectionsL134\StringCollection')] +class LeadingBackslashString extends Model +{ + +} + +#[CollectedBy(BaseCollection::class)] +abstract class TraitParent extends Model +{ + use HasCollection; +} + +class TraitParentChild extends TraitParent +{ + +} + +class TraitChild extends BaseModel +{ + use HasCollection; +} + +class ClassCollectionParent extends Model +{ + protected static string $collectionClass = StringCollection::class; +} + +class ClassCollectionChild extends ClassCollectionParent +{ + +} + +#[CollectedBy(BaseCollection::class)] +class AttributeOverClassCollection extends ClassCollectionParent +{ + +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class BaseCollection extends Collection +{ + +} + +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends Collection + */ +class StringCollection extends Collection +{ + +} + +/** @extends Collection */ +class TeamCollection extends Collection +{ + +} diff --git a/tests/PHPStan/Type/data/model-date-column-casts.php b/tests/PHPStan/Type/data/model-date-column-casts.php new file mode 100644 index 0000000000..267964bb09 --- /dev/null +++ b/tests/PHPStan/Type/data/model-date-column-casts.php @@ -0,0 +1,15 @@ +created_at); + assertType('int|null', $model->updated_at); + assertType('Carbon\CarbonImmutable|null', $model->deleted_at); +} diff --git a/tests/PHPStan/Type/data/model-factories.php b/tests/PHPStan/Type/data/model-factories.php new file mode 100644 index 0000000000..df3fe44a43 --- /dev/null +++ b/tests/PHPStan/Type/data/model-factories.php @@ -0,0 +1,402 @@ + $userClass + */ +function test(?int $foo, bool $many, UserFactory $plainFactory, string $userClass, Tag|Gizmo $tagOrGizmo, User|Account $userOrAccount): void { + assertType('Database\Factories\UserFactory', User::factory()); + assertType('Database\Factories\UserFactory', User::factory()->new()); + assertType('App\User', User::factory()->createOne()); + assertType('App\User', User::factory()->createOneQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->createMany([])); + assertType('App\User', User::factory()->makeOne()); + + assertType('App\User', User::factory()->create()); + assertType('App\User', User::factory()->createQuietly()); + assertType('App\User', User::factory()->make()); + + assertType('App\User', User::factory()->configure()->create()); + assertType('App\User', User::factory()->configure()->createQuietly()); + assertType('App\User', User::factory()->configure()->make()); + + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->create()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->createQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->make()); + assertType('App\User', User::factory(2)->createOneQuietly()); + + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->count(1)->create()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->count(1)->createQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->count(1)->make()); + assertType('App\User', User::factory()->count(2)->createOneQuietly()); + + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->times(1)->create()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->times(1)->createQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->times(1)->make()); + assertType('App\User', User::factory()->times(2)->createOneQuietly()); + + assertType('App\User', User::factory(2)->count(null)->create()); + assertType('App\User', User::factory(2)->count(null)->createQuietly()); + assertType('App\User', User::factory(2)->count(null)->createOneQuietly()); + assertType('App\User', User::factory(2)->count(null)->make()); + + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->times(3)->create()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->times(3)->createQuietly()); + assertType('App\User', User::factory(2)->times(3)->createOneQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->times(3)->make()); + + assertType('App\User', User::factory(2)->state(['foo'])->count(null)->create()); + assertType('App\User', User::factory(2)->state(['foo'])->count(null)->createQuietly()); + assertType('App\User', User::factory(2)->state(['foo'])->count(null)->createOneQuietly()); + assertType('App\User', User::factory(2)->state(['foo'])->count(null)->make()); + + assertType('Database\Factories\UserFactory', User::factory()->hasPosts()); + assertType('Database\Factories\UserFactory', User::factory()->hasPosts(3)); + assertType('Database\Factories\UserFactory', User::factory()->hasPosts(['active' => 0])); + assertType('App\User', User::factory()->hasPosts(3, ['active' => 0])->createOne()); + assertType('Database\Factories\UserFactory', User::factory()->forParent()); + assertType('Database\Factories\UserFactory', User::factory()->forParent(['meta' => ['foo']])); + + assertType('Database\Factories\UserFactory', User::factory()->unverified()); + assertType('Database\Factories\UserFactory', User::factory()->afterMaking(fn (User $user) => $user)); + assertType('Database\Factories\UserFactory', User::factory()->afterCreating(fn (User $user) => $user)); + assertType('Database\Factories\Domain\Foo\UserFactory', \App\Domain\Foo\User::factory()); + + assertType('Database\Factories\Post\PostFactory', Post::factory()); + assertType('Database\Factories\Post\PostFactory', Post::factory()->new()); + assertType('App\Post', Post::factory()->createOne()); + assertType('App\Post', Post::factory()->createOneQuietly()); + assertType('Hypervel\Database\Eloquent\Collection', Post::factory()->createMany([])); + assertType('App\Post', Post::factory()->makeOne()); + assertType('Database\Factories\Post\PostFactory', Post::factory()->afterMaking(fn (Post $post) => $post)); + assertType('Database\Factories\Post\PostFactory', Post::factory()->afterCreating(fn (Post $post) => $post)); + + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory()->count($foo)->create()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory()->count($foo)->createQuietly()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory()->count($foo)->make()); + + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory($foo)->create()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory($foo)->createQuietly()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory($foo)->make()); + + assertType('Database\Factories\UserFactory', User::factory()->trashed()); + assertType('*ERROR*', Post::factory()->trashed()); + + assertType('ModelFactories\TagFactory', Tag::factory()); + assertType('ModelFactories\Tag', Tag::factory()->create()); + assertType('Hypervel\Database\Eloquent\Collection', Tag::factory(3)->create()); + assertType('ModelFactories\TagFactory', SpecialTag::factory()); + assertType('ModelFactories\VideoFactory', Video::factory()); + assertType('ModelFactories\Video', Video::factory()->create()); + assertType('ModelFactories\ImageFactory', Image::factory()); + + assertType('Database\Factories\Shapes\ShapeFactory', Shape::factory()); + assertType('Database\Factories\Shapes\CircleFactory', Circle::factory()); + assertType('App\Shapes\Circle', Circle::factory()->create()); + assertType('Database\Factories\Shapes\ShapeFactory', Square::factory()); + // Hypervel: a generic factory keeps its template arguments. + assertType('Database\Factories\Shapes\PolygonFactory', Polygon::factory()); + assertType('Database\Factories\Shapes\HexagonFactory', Hexagon::factory()); + assertType('Database\Factories\Shapes\EllipseFactory', Ellipse::factory()); + assertType('Database\Factories\Shapes\OvalFactory', Oval::factory()); + assertType('Database\Factories\Shapes\TriangleFactory', Wedge::factory()); + assertType('Database\Factories\Shapes\ShapeFactory', Rhombus::factory()); + assertType('Database\Factories\Shapes\RectangleFactory', Cube::factory()); + + // Hypervel: merged branches with different counts keep both. + $factory = User::factory(); + if ($many) { + $factory = $factory->count(3); + } + assertType('App\User|Hypervel\Database\Eloquent\Collection', $factory->create()); + + $factory = User::factory(3); + if ($many) { + $factory = User::factory(); + } + assertType('App\User|Hypervel\Database\Eloquent\Collection', $factory->create()); + + $factory = User::factory(); + if ($many) { + $factory = $plainFactory; + } + assertType('App\User|Hypervel\Database\Eloquent\Collection', $factory->create()); + + // Hypervel: new() starts without a count, forEachSequence() sets one, and so do the static entry points. + assertType('App\User', User::factory(3)->new()->create()); + assertType('Hypervel\Database\Eloquent\Collection', User::factory()->forEachSequence(['name' => 'a'], ['name' => 'b'])->create()); + assertType('App\User', UserFactory::new()->create()); + assertType('Hypervel\Database\Eloquent\Collection', UserFactory::times(3)->create()); + + // Hypervel: createMany(), createManyQuietly() and makeMany() clear the receiver's count, but a call + // receiver returns a new factory each time. + $factory = User::factory(2); + $users = $factory->createMany(1); + assertType('Hypervel\Database\Eloquent\Collection', $users); + assertType('App\User', $factory->create()); + $factory = User::factory(2); + $factory->createManyQuietly(1); + assertType('App\User', $factory->createQuietly()); + $factory = User::factory(2); + $factory->makeMany(1); + assertType('App\User', $factory->make()); + User::factory(2)->createMany(1); + assertType('Hypervel\Database\Eloquent\Collection', User::factory(2)->create()); + + // Hypervel: a class-string receiver names its model, and an unpacked count is unknown. + assertType('Database\Factories\UserFactory', $userClass::factory()); + assertType('App\User|Hypervel\Database\Eloquent\Collection', User::factory(...[3])->create()); + + // Hypervel: newFactory() selects a static $factory default, then the model's own #[UseFactory]. + assertType('ModelFactories\GadgetFactory', Gadget::factory()); + assertType('ModelFactories\Gadget', Gadget::factory()->create()); + assertType('ModelFactories\WidgetFactory', Widget::factory()); + assertType('Hypervel\Database\Eloquent\Factories\Factory', $tagOrGizmo::factory()); + // Hypervel: a receiver union with a model that has no factory() keeps PHPStan's own handling. + assertType('Hypervel\Database\Eloquent\Factories\Factory', $userOrAccount::factory()); + + // Hypervel: only a method returning the factory's own class keeps the factory. + assertType('mixed', Gadget::factory()->payload()); + + // Hypervel: a factory's own count() may set the count differently. + assertType('App\User|Hypervel\Database\Eloquent\Collection', CountingUserFactory::new()->count(2)->create()); + + // Hypervel: a factory that declares its own templates keeps its generic type and declared results, so + // merged factories keep each model, and a selected one keeps its class and default model. + assertType('ModelFactories\GenericFactory', Sprocket::factory()); + assertType('Hypervel\Database\Eloquent\Collection|ModelFactories\Sprocket', Sprocket::factory()->make()); + $genericFactory = Sprocket::factory(); + if ($many) { + $genericFactory = Cog::factory(); + } + assertType('Hypervel\Database\Eloquent\Collection|Hypervel\Database\Eloquent\Collection|ModelFactories\Cog|ModelFactories\Sprocket', $genericFactory->make()); + assertType('Hypervel\Database\Eloquent\Collection|ModelFactories\Lever', Lever::factory()->polished()->make()); + assertType('Hypervel\Database\Eloquent\Collection|ModelFactories\Lever', LeverFactory::new()->make()); +} + +class Comment extends Model +{ + use HasFactory; + + /** @return CommentFactory */ + protected static function newFactory(): Factory + { + return CommentFactory::new(); + } + + private function test(): void + { + assertType('ModelFactories\CommentFactory', static::factory()); + } +} + +/** @extends Factory */ +class CommentFactory extends Factory +{ + public function definition(): array + { + return []; + } +} + +class Tag extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected static string $factory = TagFactory::class; +} + +class SpecialTag extends Tag +{ +} + +/** @extends Factory */ +class TagFactory extends Factory +{ + protected ?string $model = Tag::class; + + public function definition(): array + { + return []; + } +} + +#[UseFactory(VideoFactory::class)] +class Video extends Model +{ + /** @use HasFactory */ + use HasFactory; +} + +/** @extends Factory